Skip to content

External Stores: Cross-App Reads & Writes

Mutators and actions can interact with other store instances on the server. Mutators can write via ctx.mutateExternal(...); mutators and actions can read via ctx.externalStore(...). Receiving code identifies the caller via ctx.source.

External Mutations (Writes)

A mutator can dispatch mutations to a different store using ctx.mutateExternal(). This enables cross-app communication — for example, a child app notifying its parent.

typescript
notifyParent: async (ctx, input) => {
  // Update local state
  await ctx.table("status").set({
    itemKey: "notification",
    value: { sent: true },
  });

  // Dispatch a mutation to the parent store
  ctx.mutateExternal({
    storeTypeId: input.parentTypeId,
    instanceId: input.parentInstanceId,
    mutationName: "receiveChildNotification",
    input: { message: input.message },
  });
},

The target store must have a mutator with the matching name. External mutations are committed atomically after the source mutation succeeds.

Passing the target store identity

Since mutators run on both client and server, they can't access client-only APIs like Poe.parent. Instead, pass the target store identity as mutation input from client code:

javascript
// Client code — read parent identity from Poe.parent
await store.mutate.notifyParent({
  parentTypeId: Poe.parent.storeTypeId,
  parentInstanceId: Poe.parent.instanceId,
  message: "hello from child",
});

Constraints

  • Max 200 unique external mutation targets per commit (each target = (storeTypeId, instanceId) pair; multiple mutations to the same target count as one)
  • Max depth of 1 — target mutators cannot trigger further external writes (blockExternalMutations: true). External reads are not affected
  • External mutations expire after 5 minutes if not committed

External Reads

A mutator or action can read from another store using ctx.externalStore({ storeTypeId, instanceId }). The handle exposes .table(name) with get, scan, and entries (read-only).

typescript
// In a mutator or action:
const ext = ctx.externalStore({ storeTypeId: "emoji-prefs", instanceId: ctx.userId });
const favorites = await ext.table("preferences").get("favorites");
const recent = await ext.table("entries").scan({ limit: 50 }).values().toArray();

In actions, the handle also exposes .action(name, input) and .getSchema().

typescript
// In an action only:
const ext = ctx.externalStore({ storeTypeId: "chat", instanceId: "room-1" });
const result = await ext.action("summarize", { limit: 100 });
const schema = await ext.getSchema();

Reads vs writes

  • Server-only path: on the client, ctx.externalStore(...).table(...).get(...) is a no-op stub that returns empty results. Don't depend on optimistic external reads
  • No depth limit: reads are stateless (no commits, no broadcasts, no retries) and are allowed inside external dispatch targets and action-invoked mutators — unlike writes
  • Bounded: a single scan/list returns at most MAX_EXTERNAL_READ_ITEMS (1000) and MAX_EXTERNAL_READ_BYTES (1 MB). Response carries a truncated flag when limits were hit
  • Timeout: EXTERNAL_READ_TIMEOUT_MS = 5s for the dispatch RPC

Client-side external reads

Iframe apps can read from another store via Poe.externalStore({ storeTypeId, instanceId }) — call await ext.waitForBootstrap() first, then await ext.query((tx) => tx.table(...).get(...)). This is a one-shot snapshot read against locally-synced data.

ctx.source — identifying the caller

ctx.source is a ContextSource discriminated union exposed on MutationContext, QueryContext, and ActionContext. It tells receiving code who triggered this request.

typescript
type ContextSource =
  | { type: "user"; userId: string }
  | {
      type: "external-store";
      userId: string;
      store: { typeId: string; instanceId: string };
      room: { storeTypeId: string; instanceId: string };
    }
  | { type: "system" };
VariantWhen you see it
"user"Normal client mutation/query/action
"external-store"Dispatched from another store via ctx.mutateExternal(...). ctx.source.store carries the source identity (typeId + instanceId); ctx.source.userId is the user who triggered the source mutation; ctx.source.room carries the source's resolved room ref (see below)
"system"System mutation (e.g. platform-issued addUser) or hook invocation. No userId field

Using ctx.source in receiving mutators

External-dispatch targets typically gate on ctx.source.type and read identity from ctx.source.store — don't make callers stuff typeId/instanceId into the input.

typescript
receiveActivity: async (ctx, input) => {
  if (ctx.source.type !== "external-store") {
    throw new Error("receiveActivity must be called via external dispatch");
  }
  const { typeId, instanceId } = ctx.source.store;
  const sourceUserId = ctx.source.userId;
  const sourceRoom = ctx.source.room; // always defined on external-store
  // ...
},

ctx.source on the client (optimistic) vs server (authoritative)

ctx.source is populated identically when the mutator runs optimistically on the client and authoritatively on the server, so branching on ctx.source.type is rebase-safe. To gate behavior between optimistic and server runs intentionally, use ctx.isServer.

Note that userId is only present on the "user" and "external-store" variants — "system" has no userId. Narrow on ctx.source.type before reading it.

Platform-augmented fields on ctx.source

The Poe app platform stamps extra fields onto ctx.source via TypeScript module augmentation. They are populated by the trusted server from authoritative state — never read from app input — and ride through external dispatch automatically (createExternalStoreOrigin propagates whole-origin fields from the dispatching source's origin; room is freshly resolved server-side from the source's pinned $$system:room).

FieldTypeVariantsPopulated when
ctx.source.room{ storeTypeId: string; instanceId: string }external-store only — requiredEvery cross-store dispatch carries the source's resolved room ref. If the source's $$system:room is {type:"self"} (the default for self-contained stores), room equals the source store's own identity. If the source is a member of a foreign room, room is that room ref.
ctx.source.parent{ typeId: string; instanceId: string } | undefinedall variantsThe instance is a sub-app opened via apps.openChild. Carries the immediate parent's identity — for a 3-level mount the grandchild's parent is the sub-app, not the root. Undefined for root apps.
typescript
recordOpenedFromContext: async (ctx, input) => {
  // ctx.source.parent is undefined when the request comes from a root app.
  const parent = ctx.source.parent;
  // ctx.source.room is REQUIRED on external-store — narrow on type first.
  const room = ctx.source.type === "external-store" ? ctx.source.room : null;
  await ctx.table("audit").set({
    itemKey: input.eventId,
    value: {
      sourceRoom: room,
      parentTypeId: parent?.typeId ?? null,
    },
  });
},

The parent field is not stamped on the iframe-side optimistic run of a mutator (platform_fields are stamped host-side). Read it under ctx.isServer or wait for the server-confirmed row when assertions depend on it. The room field is server-resolved per-dispatch and may be missing on optimistic runs; receiving code that depends on room should gate on ctx.isServer (or wait for the server-confirmed row) when asserting in tests.