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 } }
  | { 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
"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;
  // ...
},

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 (or compare against SYSTEM_USER_ID from @synced-store/shared if you want to treat system mutations as a synthetic user).

Platform-augmented fields on ctx.source

The Poe app platform stamps two extra fields onto every ctx.source (regardless of variant) via TypeScript module augmentation. They are populated by the trusted host kernel from server-side state — never read from app input — and ride through external dispatch automatically (createExternalStoreOrigin carries them from the dispatching source's origin to the target's ctx.source).

FieldTypePopulated when
ctx.source.roomIdstring | undefinedThe instance was opened with a <poe-app room-id="..."> attribute by the trusted root app, or any descendant inherits the ancestor's roomId. Undefined for out-of-room instances.
ctx.source.parent{ typeId: string; instanceId: string } | undefinedThe 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) => {
  // Track which app opened this instance (e.g. for breadcrumb UI).
  // ctx.source.parent is undefined when the request comes from a root app.
  const parent = ctx.source.parent;
  await ctx.table("audit").set({
    itemKey: input.eventId,
    value: { roomId: ctx.source.roomId ?? null, parentTypeId: parent?.typeId ?? null },
  });
},

These fields are not present on the iframe-side optimistic run of a mutator (platform_fields are stamped host-side, not iframe-side). Read them under ctx.isServer or wait for the server-confirmed row when assertions depend on them.