setup-offline-profile
SkillDev toolsUse 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.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the setup-offline-profile skill
What this skill tells your AI
The instructions your AI receives, as published by microsoft/power-platform-skills in plugins/mobile-apps/skills/setup-offline-profile/SKILL.md and read by ahel’s review.
Shared instructions: shared-instructions.md — read first.
References:
- offline-profile-schema.md — Dataverse entity field map
- dataverse-offline-api.md — Web API recipes for profile / item / association POSTs
- offline-profile-reconciliation.md — the
schemaColumnsbaseline 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: authoring only. This skill creates the Dataverse entities (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 npx power-apps init and its schema is controlled upstream). It also does NOT scaffold an offline runtime (SQLite store, sync engine, write queue) in the generated app — that's gated on upstream @microsoft/power-apps-native-host runtime support.
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
- Verify project & auth → 2. Resolve mode (create vs extend) → 3. Spawn architect agent → Gate 1 (table prerequisites) → 4. Run the internal
enable-tables-offlineworkflow 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
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:
# 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-appfirst." - Neither
.datamodel-manifest.jsonnordocs/plan-artifacts/.datamodel-manifest.json→ "Run/add-dataversefirst — 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). - 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 via /create-mobile-app Step 6.85 ("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:
-
GET /mobileofflineprofiles(<profileId>)?$expand=MobileOfflineProfile_MobileOfflineProfileItemto 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,
selectedcolumnsempty 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 asdone.
-
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 -
On
Resume→ jump to the computed step. Skip already-committed items by matchingselectedentitytypecode. -
On
Start fresh→DELETE /mobileofflineprofiles(<profileId>)(cascade-deletes items + associations), clear memory-bank section, continue to Step 2 as new. -
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…"
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
deleteis 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:
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→ continueDONE_WITH_CONCERNS: <list>→ surface concerns at Gate 2 / Gate 3 where relevant; continueNEEDS_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:
══════════════════════════════════════════════════════════════════════
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)
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)
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
skipto keep<current name>."Then: "Type the new description, or
skipto 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):
header : Scope <table>
question : "Row scope for `<table-logical-name>`?"
options :
- Related rows only (child of a parent table; default for pure children)
- All records (default for shared catalogs like product / contact)
- User's own rows (Organization-scoped + recordsownedbyme=true; default for personal transactional data)
- Organization's rows (Organization-scoped, all owners — broadest non-All)
Map answers → recordDistributionCriteria (0=Related, 1=All, 2=Organization) + the recordsownedby* sub-flags. If the user picks "User's own rows" set recordsownedbyme=true; if "Organization's rows" leave all three sub-flags false.
Per-table sync interval — one AskUserQuestion per table:
header : Sync <table>
question : "How often should `<table-logical-name>` sync?"
options :
- Every 5 min — hot transactional data (Recommended for tables users edit live)
- Every 15 min — typical
- Every 30 min — slower-moving
- Every 60 min — static catalogs (lowest battery cost)
(Range is 5–1440 min server-side; custom values outside these four come via the Other free-text option that AskUserQuestion always provides — validate 5 ≤ N ≤ 1440.)
Per-table column selection — text fallback (column lists are too varied for multi-choice):
"For
<table>, type column changes one per line:exclude <column>to drop a column from syncinclude <column>to add one that's currently excludedallto reset to every column from the manifestskipto leave columns unchanged"
Validate every named column exists in .datamodel-manifest.json; reject typos with the closest-match column suggestion.
Step 3.5e — Re-confirm
After all picked sub-flows complete, re-render the summary (Step 3.5a format) with the changed rows marked → updated in a different colour or with a leading *. Then re-run Step 3.5b — same three-option AskUserQuestion. The user can adjust again, accept, or cancel.
This loop is bounded by user patience, not a hard limit. If they pick "Adjust" repeatedly without ever choosing "Accept" or "Cancel", that's their prerogative — every loop is reversible and nothing has hit Dataverse yet.
Step 3.5f — Memory bank checkpoint (after Accept and publish)
## Offline profile
status: in-progress
profileName: <name>
mode: create-new | extend
configReview: accepted
Design rationale. The original Step 3.5 used free-text replies ("type
acceptor describe edits in English") to keep things conversational. In practice users typed responses the regex parsers didn't recognise —change scope of contact to teamonly,set sync to 10, etc. — and the skill either silently dropped the edit or asked a clarifying question that drove additional confusion. TheAskUserQuestionflow above eliminates parsing risk for the enumerable fields (scope, sync interval, top-level decision) while preserving the free-text path for the genuinely free-form fields (name, description, column lists). Net result: zero ambiguous interactions for the common adjustments, fewer typing-induced errors, parity with/create-mobile-app's plan-gate UX.
Step 4 — Run the internal enable-tables-offline workflow if needed
Telemetry checkpoint: enable_dataverse_tables_offline
If Gate 1 identified any table needing change, read and execute ${PLUGIN_ROOT}/skills/enable-tables-offline/SKILL.md with the table list as its $ARGUMENTS:
$ARGUMENTS: cr123_note,cr123_visit
Wait for it to return. Expected final line: DONE or DONE_WITH_CONCERNS:.
If BLOCKED, propagate the block up — STOP.
If all tables were already enabled, skip this step.
Step 5 — POST profile shell
Telemetry checkpoint: create_offline_profile_shell
Print before starting:
"→ Creating MobileOfflineProfile record (Name + Description only)…"
For create-new mode:
node "${PLUGIN_ROOT}/scripts/dataverse-request.js" <envUrl> POST \
"mobileofflineprofiles" \
--body '{
"name": "<app name> Offline Profile",
"description": "<auto-generated description referencing app name + scope summary>"
}' \
--include-headers
Capture mobileofflineprofileid from the OData-EntityId response header (matches the pattern in /add-dataverse Step 5b).
For extend mode: re-use the existing mobileofflineprofileid from Step 2.
Write to memory-bank.md:
## Offline profile
status: in-progress
profileId: <guid>
profileName: <name>
mode: create-new | extend
gate1: approved
(Gate 2 — REMOVED, consolidated into Step 3.5)
Step 6 — POST profile items
Telemetry checkpoint: add_tables_to_offline_profile
Print before starting:
"→ Creating MobileOfflineProfileItem records (one per table, sequential)…"
⚠️ Concurrency rule. Profile items reference each other implicitly via the parent profile. Issue POSTs sequentially, one at a time. The mobileofflineprofileitem entity does NOT hold the metadata lock that EntityMetadata does, but the validation pass on each POST reads neighboring items — parallel POSTs occasionally return 412 PreconditionFailed. Sequential is the safe path.
For each table, in sequence:
node "${PLUGIN_ROOT}/scripts/dataverse-request.js" <envUrl> POST \
"mobileofflineprofileitems" \
--body '{
"name": "<table display name>",
"regardingobjectid@odata.bind": "/mobileofflineprofiles(<profileId>)",
"selectedentitytypecode": "<table-logicalname>",
"recorddistributioncriteria": <0|1|2>,
"recordsownedbyme": <bool>,
"recordsownedbymyteam": <bool>,
"recordsownedbymybusinessunit": <bool>,
"getrelatedentityrecords": true,
"syncintervalinminutes": 10
}' \
--include-headers
Capture each mobileofflineprofileitemid from the response header. Store in a local map tableLogicalName → itemId — needed for Step 7 (associations) and Step 9 (offline-profile.json).
selectedcolumns is NOT set here — added at Gate 3 / Step 7 once the user confirms the column subset.
Print ✓ <table> after each 2xx.
(Gate 3 — REMOVED, consolidated into Step 3.5)
Step 7 — POST associations + PATCH selectedcolumns
Telemetry checkpoint: configure_offline_profile_associations
Print before starting:
"→ Creating association rows + PATCHing selectedcolumns on each profile item…"
Step 7a — POST mobileofflineprofileitemassociation rows
Empirical 2026-05-24 + 2026-05-25 capture from maker portal unblocked association creation. Recipe (no selectedrelationshipsschema field — server fills it):
node "${PLUGIN_ROOT}/scripts/dataverse-request.js" <envUrl> POST \
"mobileofflineprofileitemassociations" \
--body '{
"name": "<relationshipSchemaName>",
"relationshipdisplayname": "<relationshipSchemaName>",
"relationshipid": "<MetadataId-of-1:N-relationship-from-architect>",
"regardingobjectid@odata.bind": "/mobileofflineprofileitems(<PARENT-item-id>)"
}' \
--include-headers
⚠️ Critical direction rule.
regardingobjectidis the parent (1-side) profile item. For anaccount → orderline1:N relationship, the association lives on theaccountprofile item with the relationshipaccount_orderline(read fromEntityDefinitions(LogicalName='account')/OneToManyRelationships). POSTing on theorderline(child) side failsPublishXmlwith0x80071140 — no relationships are specified for this Related-only table. This is the canonical bug — architect's Step 5 now explicitly walks parents'OneToManyRelationshipsto produce parent-keyed output.
For each (PARENT-item, 1:N-relationship-to-child) pair from the architect's proposal:
- Resolve
relationshipid(MetadataId GUID) viaEntityDefinitions(LogicalName='<PARENT-table>')/OneToManyRelationshipsfiltering bySchemaName === <relationshipSchemaName>. Cache the lookup per parent table. - POST the association (sequential — parallel POSTs occasionally return 412 PreconditionFailed).
- Capture the new
mobileofflineprofileitemassociationidfrom theOData-EntityIdresponse header.
Skip-when-redundant rule. If the parent profile item has
recorddistributioncriteria=1(All records), the architect should have pruned associations on it (per Step 5 pruning rule). Defensively: if any propagate through, SKIP them at POST time. Print:↷ Skipping <association> on All-records parent <table> — redundant, all rows download anyway.
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 859
- Forks
- 176
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
setup-offline-profile- Source
- github.com/microsoft/power-platform-skills