- name
- setup-offline-profile
- description
- Use when the user wants to enable offline mode for a Power Apps mobile app and create a Mobile Offline Profile in Dataverse — designs per-table row scope, relationships, columns, and sync frequency through a 3-gate approval flow.
- user-invocable
- true
- allowed-tools
- Read, Edit, Write, Grep, Glob, Bash, AskUserQuestion, EnterPlanMode, ExitPlanMode, Task
- model
- opus
> **Plugin check**: Run `node "${PLUGIN_ROOT}/scripts/check-version.js"` - if it outputs a message, show it to the user before proceeding.
**Shared instructions: [shared-instructions.md](${PLUGIN_ROOT}/shared/shared-instructions.md)** — read first.
**References:**
- [offline-profile-schema.md](${PLUGIN_ROOT}/shared/references/offline-profile-schema.md) — Dataverse entity field map
- [dataverse-offline-api.md](${PLUGIN_ROOT}/shared/references/dataverse-offline-api.md) — Web API recipes for profile / item / association POSTs
- [offline-profile-reconciliation.md](${PLUGIN_ROOT}/shared/references/offline-profile-reconciliation.md) — the `schemaColumns` baseline this skill writes + the lifecycle delta check that consumes it
# Setup Offline Profile
End-to-end wizard for creating a Dataverse Mobile Offline Profile that the app (and any other compatible Power Apps client) can use to download data for offline access.
**Scope of v0**: configuration only. This skill creates records in the existing
Dataverse Mobile Offline Profile tables (`mobileofflineprofile`,
`mobileofflineprofileitem`, `mobileofflineprofileitemassociation`) and writes
the full app-level offline config — profile metadata, per-table scope, and the
temporary SDK-workaround fields — to `offline-profile.json`. **This skill does
NOT modify `power.config.json`** (that file is owned by `pa app init`
and its schema is controlled upstream). The template already bundles
`@microsoft/power-apps-native-offline`; `@microsoft/power-apps-native-host`
consumes the profile and owns local SQLite access, queued synchronization,
reconnect behavior, and status UX. This skill does not scaffold duplicate
app-owned offline runtime code.
**Out of scope for v0**:
- Custom filter mode (`recorddistributioncriteria=3`, savedquery picker) — defer to v0.5
- User/team membership assignment — split into `/assign-offline-profile`
- Row-count download estimation — split into `/preview-offline-scope`
## Workflow
1. Verify project & auth → 2. Resolve mode (create vs extend) → 3. Spawn architect agent → **Gate 1** (table prerequisites) → 4. Run the internal `enable-tables-offline` workflow if needed → 5. POST profile shell → **Gate 2** (per-table row scope) → 6. POST profile items → **Gate 3** (relationships + columns + sync) → 7. POST associations → 8. Validate + publish → 9. Persist artifacts → 10. Summary
---
### Step 1 — Verify project & auth
```bash
test -f power.config.json && test -f app.config.js
# Manifest lives at either root (legacy) or docs/plan-artifacts/ (newer scaffolds)
MANIFEST=$(test -f .datamodel-manifest.json && echo ".datamodel-manifest.json" || \
(test -f docs/plan-artifacts/.datamodel-manifest.json && echo "docs/plan-artifacts/.datamodel-manifest.json"))
test -n "$MANIFEST" && echo "✓ manifest at $MANIFEST"
node "${PLUGIN_ROOT}/scripts/resolve-environment.js" "$(node -e \"console.log(require('./power.config.json').environmentId)\")"
```
Capture **Environment URL** for `<envUrl>` and **manifest path** for the architect spawn (Step 3) and the artifacts write (Step 9).
**Web-only target detection** — Mobile Offline Profiles only apply to native targets (iOS/Android). If the project is web-only, the profile will be created in Dataverse but **the generated app will never use it**:
```bash
# Inspect platforms declared in app.config.js
node -e "
const c = require('$(pwd)/app.config.js');
const platforms = c?.expo?.platforms ?? [];
const hasNative = platforms.includes('ios') || platforms.includes('android');
console.log(JSON.stringify({ platforms, hasNative }));
" 2>/dev/null
```
| `hasNative` | Action |
|---|---|
| `true` (has `ios` and/or `android`) | Continue normally |
| `false` (web-only or no platforms) | Print: `⚠ This project only targets web — Mobile Offline Profiles don't apply (they require iOS/Android). Continuing will create the profile in Dataverse but no app will use it.` Ask via `AskUserQuestion`: "Continue anyway?" Default No. |
| Parse error / app.config.js missing key | Warn, but assume native (don't block on a parser quirk) |
STOP conditions:
- No `power.config.json` → "Run `/create-mobile-app` first."
- Neither `.datamodel-manifest.json` nor `docs/plan-artifacts/.datamodel-manifest.json` → "Run `/add-dataverse` first — offline profiles require a data model."
- Environment resolution failure → standard auth recovery (`az login --tenant <env-tenant>` or provide environment URL directly; see [shared-instructions.md](${PLUGIN_ROOT}/shared/shared-instructions.md)).
- Web-only + user declines override → STOP. Print: `Offline profile creation skipped — no native target.`
#### Step 1a — Environment consistency check
Same as `/add-dataverse` Step 3a — verify `power.config.json` resolves and `az` can token for the target tenant. STOP if it cannot; user must re-auth with `az login --tenant <env-tenant>`.
#### Step 1b — Resume check
Read `memory-bank.md` `## Offline profile` block. Decide based on `status`:
| `status` value | Action |
|---|---|
| (section absent) OR `status: none` | First-time run. Continue to Step 2. |
| `status: not-applicable` | User previously opted out during `/create-mobile-app` offline-profile setup ("doesn't need offline support"). Re-confirm: "Memory-bank says this app doesn't need offline. Override and proceed? (y/N)". Default N stops here. |
| `status: done` AND a profile matching `profileId` still exists in env | Already complete. Print summary from the memory-bank block; ask user if they want to `/edit-offline-profile` (v0.2) or just exit. |
| `status: done` BUT `GET /mobileofflineprofiles(<profileId>)` returns 404 | Profile was deleted externally (maker portal or another env). Treat as `none`; clear the section; continue to Step 2. |
| `status: in-progress` AND profile exists in env | **Resume flow** — see below. |
| `status: in-progress` BUT profile doesn't exist in env | Memory-bank stale. Auto-clean: clear the section, log `recovered from stale in-progress state`, continue to Step 2 as a fresh run. |
**Resume flow** — when memory-bank has `status: in-progress` AND the profile still exists:
1. `GET /mobileofflineprofiles(<profileId>)?$expand=MobileOfflineProfile_MobileOfflineProfileItem` to compute what's actually been committed:
- 0 items → profile shell exists, items not yet POSTed. Resume from Step 6.
- 1+ items, missing some from manifest → resume from Step 6, skipping items already present.
- All items present, `selectedcolumns` empty on ≥1 → resume from Step 7 (PATCH).
- All items present, all have `selectedcolumns` → resume from Step 8 (Publish).
- `componentstate=0` (Published) → memory-bank lies; treat as `done`.
2. Ask the user one consolidated AskUserQuestion (NOT a per-step approval):
```
Resume from <computed step> on profile "<name>" (<profileId>)?
Items already committed: <N> of <M>
PATCHes already applied: <K>
Published: <yes|no>
Options:
- Resume from where it left off (recommended)
- Start fresh — delete the half-built profile and re-run from Step 5
- Cancel
```
3. On `Resume` → jump to the computed step. Skip already-committed items by matching `selectedentitytypecode`.
4. On `Start fresh` → `DELETE /mobileofflineprofiles(<profileId>)` (cascade-deletes items + associations), clear memory-bank section, continue to Step 2 as new.
5. On `Cancel` → STOP, leave memory-bank untouched.
**Memory-bank checkpoint contract:** the skill writes `status: in-progress` + `profileId` immediately after Step 5 (profile shell POST). Each subsequent step updates a `lastSuccessfulStep:` field so resume knows where to pick up. On `BLOCKED:` from any step, the skill leaves `status: in-progress`; on `DONE`, it writes `status: done` in Step 9c.
**Idempotency on POST retries:** if Step 6 re-POSTs an item with the same `selectedentitytypecode` against the same parent profile, Dataverse may return `409 Conflict` ("duplicate"). The wrapper `dataverse-request.js` treats this as silent success via its `looksLikeDuplicate` logic — safe to re-attempt.
### Step 2 — Resolve mode (create vs extend vs reconcile)
**Telemetry checkpoint: `resolve_offline_profile_mode`**
**Print before starting:**
> "→ Checking for existing offline profiles in the environment…"
```bash
node "${PLUGIN_ROOT}/scripts/dataverse-request.js" <envUrl> GET \
"mobileofflineprofiles?\$select=mobileofflineprofileid,name,description,publishedon"
```
Decision tree — evaluate in order:
| # | Condition | Action |
|---|---|---|
| 1 | Step 1b already detected `status: in-progress` in memory-bank | Resume flow (handled in Step 1b). Do not re-evaluate here. |
| 2 | `offline-profile.json` has top-level `profileId: <X>` AND profile `<X>` still exists in env | **Collision case — ASK USER.** AskUserQuestion: (a) Extend the pinned profile (re-architect against current data model + add/PATCH items as needed), (b) Delete the pinned profile and create fresh (irreversible — cascade-deletes items + associations + memberships), (c) Cancel. Default = (a) extend. NEVER silently delete. |
| 3 | `offline-profile.json` has top-level `profileId: <X>` AND profile `<X>` does NOT exist (404 from GET) | `offline-profile.json` is stale (env reset, profile manually deleted). Delete the local file, continue to row #4. |
| 4 | Any existing profile in env has `name` matching this app's name (case-insensitive substring match on `power.config.appDisplayName` or directory name) | **ASK USER.** AskUserQuestion: (a) Extend the name-matching profile, (b) Create a new profile (the name-matching one may belong to another app — your call), (c) Cancel. No default — both are legitimate. |
| 5 | Zero profiles in env | Mode = `create-new`. Continue to Step 3. |
| 6 | 1+ profiles in env but no name match + no pin | Mode = `create-new` (the env has unrelated profiles). Continue to Step 3. Print one-line note: `↷ <N> unrelated profiles exist in env; not extending — see /edit-offline-profile to manage them.` |
> **Why row #2 is critical (empirical 2026-05-25)**: the chanel-rm and FCB Tracker test runs hit this case. Without this check, the skill cascade-deleted the pinned profile silently. After this patch, the user gets a clear three-way choice and `delete` is always an explicit action.
> **Extend mode (rows #2a, #4a) implementation**: re-spawn the architect with `Mode: extend, existingProfileId: <X>`. The architect compares its current proposal to the on-server items + associations and outputs three lists: (i) items to ADD, (ii) items to PATCH (scope/columns/sync diffs), (iii) items to DELETE (in-server but not in current data model). Surface to the user at Gate 2. After approval, the skill issues incremental writes — no profile-shell POST.
### Step 3 — Spawn architect agent
**Telemetry checkpoint: `design_offline_profile_scope`**
**Print before starting:**
> "→ Spawning mobile-app:offline-profile-architect agent (read-only) to design the profile…"
Spawn via `Task`:
```text
agent: mobile-app:offline-profile-architect
prompt:
Working directory: <workdir>
Plugin root: ${PLUGIN_ROOT}
Environment URL: <envUrl>
Publisher prefix: <prefix>
Mode: default
```
The agent returns `_offline_section.md` in the working directory. Read it. Parse its first-line status code:
- `DONE` → continue
- `DONE_WITH_CONCERNS: <list>` → surface concerns at Gate 2 / Gate 3 where relevant; continue
- `NEEDS_CONTEXT: <missing>` → resolve the missing context (most often: data model file absent), re-spawn once (cap: 2 retries). If still failing, STOP with the agent's reason.
- `BLOCKED: <reason>` → STOP, surface to user, do not silently retry.
### Step 3.5 — Configuration review (interactive AskUserQuestion flow)
**Telemetry checkpoint: `review_offline_profile_configuration`**
**Print before starting:**
> "→ Presenting the proposed offline profile configuration. You'll tap an option to accept, adjust, or cancel — no need to type."
The skill renders the proposal as a one-screen summary (read-only context) and then drives the decision through structured `AskUserQuestion` prompts — the same click-style pattern `/create-mobile-app` uses for its 4 plan gates. This replaces the older "type `accept` / plain-English edits / `cancel`" reply flow, which was hit-or-miss when users typed something the parser didn't recognize.
#### Step 3.5a — Print the summary (informational only)
Substitute real values from `_offline_section.md` and the architect output:
```text
══════════════════════════════════════════════════════════════════════
Mobile Offline Profile — Proposed Configuration
══════════════════════════════════════════════════════════════════════
PROFILE METADATA
Name : <proposed name>
Description : <proposed description, or "(none)">
Mode : create-new | extend existing (<profileId>)
TABLE PREREQUISITES (IsAvailableOffline + ChangeTrackingEnabled)
cr123_note ❌/❌ → will be enabled
cr123_visit ✅/❌ → ChangeTracking will be enabled
contact ✅/✅ → no change
PER-TABLE CONFIG
▸ cr123_note (Organization rows, User's rows)
Sync : every 10 min
Columns : 12 (cr123_title, cr123_body, cr123_visitid, …, modifiedon, createdon)
▸ cr123_visit (Related rows only)
Sync : every 10 min
Columns : 8 (cr123_name, cr123_date, …, modifiedon)
▸ contact (All records)
Sync : every 60 min
Columns : 5 (fullname, emailaddress1, …, modifiedon)
RELATIONSHIPS (will be POSTed as mobileofflineprofileitemassociation rows)
cr123_note → cr123_note_visit (links to cr123_visit)
cr123_note → cr123_note_image (includes image bytes)
══════════════════════════════════════════════════════════════════════
```
No prompt is shown alongside this text — it's informational context, immediately followed by the interactive prompt below.
#### Step 3.5b — Top-level decision (single `AskUserQuestion`)
```text
header : Offline profile
question : "Accept the proposed offline profile configuration and publish to Dataverse?"
options :
- Accept and publish (Recommended)
- Adjust before publishing
- Cancel — abort, nothing is mutated
```
Outcomes:
| User picks | Action |
|---|---|
| **Accept and publish** | Write memory bank checkpoint (see below), continue to Step 4 (enable prereqs if needed) → Step 5 (POST shell) → Step 6 (POST items) → Step 7 (PATCH selectedcolumns) → Step 8 (publish) → Step 9 (persist) → Step 9.5 (verify) → Step 10 (summary). **No further prompts** unless something fails. |
| **Cancel** | STOP. No Dataverse mutations. No artifacts written. Update `memory-bank.md` with `status: cancelled-at-config-review`. |
| **Adjust before publishing** | Continue to Step 3.5c. |
#### Step 3.5c — Pick the areas to adjust (single `AskUserQuestion`, multiSelect)
```text
header : Adjust which
question : "Which sections of the proposal do you want to change?"
multiSelect: true
options :
- Profile name or description
- Per-table row scope (who's rows get synced)
- Per-table sync interval
- Per-table column selection
```
Whatever the user picks drives Step 3.5d. If they pick nothing (closing the dialog), treat that as a no-op and loop back to Step 3.5b.
#### Step 3.5d — Drill-down per area
For each area the user picked in 3.5c, run the corresponding sub-flow. Apply edits to an in-memory config copy as you go; nothing hits Dataverse until Step 3.5e.
**Profile name or description** — text prompt (free-form input, no enumeration possible):
> "Type the new profile name, or `skip` to keep `<current name>`."
>
> Then: "Type the new description, or `skip` to keep `<current description>`."
Validate name ≤ 100 chars; trim whitespace; reject empty.
**Per-table row scope** — one `AskUserQuestion` per table (batch up to 4 tables per call; 5+ tables → multiple calls):
```text
header : Scope <table>
question : "Row scope for `<table-logical-name>`?"
options :
- Related rows only (child of a parent table; default for pure children)
Auf GitHub ansehen