setup-offline-profile

SkillDev tools

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.

Available today. Use it from your connected AI after setup.

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:

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

  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

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
hasNativeAction
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 keyWarn, 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).
  • 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 valueAction
(section absent) OR status: noneFirst-time run. Continue to Step 2.
status: not-applicableUser 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 envAlready 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 404Profile 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 envResume flow — see below.
status: in-progress BUT profile doesn't exist in envMemory-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 freshDELETE /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…"

node "${PLUGIN_ROOT}/scripts/dataverse-request.js" <envUrl> GET \
  "mobileofflineprofiles?\$select=mobileofflineprofileid,name,description,publishedon"

Decision tree — evaluate in order:

#ConditionAction
1Step 1b already detected status: in-progress in memory-bankResume flow (handled in Step 1b). Do not re-evaluate here.
2offline-profile.json has top-level profileId: <X> AND profile <X> still exists in envCollision 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.
3offline-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.
4Any 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.
5Zero profiles in envMode = create-new. Continue to Step 3.
61+ profiles in env but no name match + no pinMode = 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:

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:

══════════════════════════════════════════════════════════════════════
 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 picksAction
Accept and publishWrite 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.
CancelSTOP. No Dataverse mutations. No artifacts written. Update memory-bank.md with status: cancelled-at-config-review.
Adjust before publishingContinue 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 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):

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 sync include <column> to add one that's currently excluded all to reset to every column from the manifest skip to 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 accept or 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. The AskUserQuestion flow 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. regardingobjectid is the parent (1-side) profile item. For an account → orderline 1:N relationship, the association lives on the account profile item with the relationship account_orderline (read from EntityDefinitions(LogicalName='account')/OneToManyRelationships). POSTing on the orderline (child) side fails PublishXml with 0x80071140 — no relationships are specified for this Related-only table. This is the canonical bug — architect's Step 5 now explicitly walks parents' OneToManyRelationships to produce parent-keyed output.

For each (PARENT-item, 1:N-relationship-to-child) pair from the architect's proposal:

  1. Resolve relationshipid (MetadataId GUID) via EntityDefinitions(LogicalName='<PARENT-table>')/OneToManyRelationships filtering by SchemaName === <relationshipSchemaName>. Cache the lookup per parent table.
  2. POST the association (sequential — parallel POSTs occasionally return 412 PreconditionFailed).
  3. Capture the new mobileofflineprofileitemassociationid from the OData-EntityId response 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