Skip to content

Backend API

The poe-tiles-sdk/v1/backend.js module is the single import for everything a Joiner activity needs on the backend side. It wraps @synced-store/backend with platform defaults — system tables and hooks are pre-wired so apps don't configure them manually.

typescript
import {
  defineSchema, table, singletonTable, item,
  defineBackendConfig,
} from "poe-tiles-sdk/v1/backend.js";

Schema Builders

defineSchema()

Define a synced-store schema with platform system tables automatically configured. System tables ($users, $userInfo) are pre-wired — no need to pass systemTableTypes.

typescript
import { defineSchema, table } from "poe-tiles-sdk/v1/backend.js";
import { z } from "zod";
import { TILE_SCHEMA_VERSION } from "./tile-schema-version";

export const mySchema = defineSchema({
  schemaVersion: TILE_SCHEMA_VERSION,
  tables: {
    messages: {
      schema: table(z.object({
        text: z.string(),
        sender: z.string(),
      })),
    },
  },
  mutators: {
    send: {
      description: "Send a message",
      input: z.object({ text: z.string() }),
    },
  },
  actions: {
    summarize: {
      description: "Summarize the conversation with AI",
      input: z.object({}),
    },
  },
});

table(schema)

Define a homogeneous table where every row has the same Zod schema.

singletonTable(items)

Define a singleton table with a fixed set of typed keys.

typescript
import { singletonTable, item } from "poe-tiles-sdk/v1/backend.js";
import { z } from "zod";

const settings = singletonTable({
  theme: item(z.enum(["light", "dark"])),
  language: item(z.string()),
});

item(schema)

Define a single item within a singletonTable.

defineBackendConfig()

Bundle your schema, mutators, actions, and hooks into a typed backend config. Hook names (onInit, onAddUsers, onRemoveUser, onAnonymizeUser, onSetTitle, onGrantPermission, onRevokePermission, onAddTileInstanceToRoom, onChangeTileParent, onAddChildTile, onChildInstancesAdded, onRoomMemberInstanceMovedOut) are pre-wired with their input types.

Every backend hook should also be represented in defineClientConfig({ hooks }) so poe-tiles-kernel can supply matching optimistic startup invocations when opening a fresh prepared store. Put deterministic hook work in a browser-safe shared helper and import that helper into both configs. Keep only truly server-only effects backend-local, or guard them with if (ctx.isServer).

typescript
import { defineBackendConfig } from "poe-tiles-sdk/v1/backend.js";
import { mySchema } from "./schema";
import { myMutators } from "./mutators/index";
import { myActions } from "./actions";

export const myBackendConfig = defineBackendConfig({
  schema: mySchema,
  mutators: myMutators,
  actions: myActions,
  hooks: {
    onAddUsers: async (ctx, { userId }) => {
      // Initialize user data when they join
    },
    onRemoveUser: async (ctx, { userId }) => {
      // Clean up when a user leaves
    },
  },
});

System Hooks

HookInputWhen it fires
onInit{ parentRoomUsers }A new child-room activity is initialized with parent-room context (parentRoomUsers is a read-only roster snapshot)
onAddUsers{ userId: string }User joins the store instance
onRemoveUser{ userId: string }User leaves the store instance
onAnonymizeUser{ userId: string }User is hard-deleted/anonymized
onSetTitle{ userId: string; title: string | null }Store title changes; title is null when the custom title is cleared/reset to the default — handle the reset case rather than interpolating null
onGrantPermission{ userId: string; permission: string }Permission granted
onRevokePermission{ userId: string; permission: string }Permission revoked
onAddTileInstanceToRoom{ storeTypeId: string; instanceId: string }A new app instance is registered as a member of this room (fires on the room store after a new $room_member_instances row is written; suppressed on idempotent re-registers)
onChangeTileParent{ previousParent?: PoeTileRoom; parent?: PoeTileRoom }This activity instance gains, loses, or changes its parent rootGroup
onAddChildTile{ typeId: string; instanceId: string; room: PoeTileRoom; reason?: "adoption" }A rootGroup gains one net-new child activity row
onChildInstancesAdded{ instances: Array<{ typeId: string; instanceId: string; roomTypeId: string; roomId: string; reason?: "adoption" }> }A rootGroup gains one or more net-new child activity rows
onRoomMemberInstanceMovedOut{ storeTypeId, instanceId, toRoom, ... }A member app instance is removed from this room

Common hook uses:

  • onInit is for deterministic one-time bootstrap. Use it to seed app-owned rows such as an initial activity message ("{name} started the group"). parentRoomUsers is a read-only snapshot of the parent room's roster — never a way to admit members. Membership is platform-owned: the host seats the launcher at genesis (both users when the activity is launched from a 2-person room, i.e. a DM); larger rooms stay picker-driven.
  • onAddUsers runs after the membership row is written. Use it to auto-seat newly added users into app-local roles, seats, teams, or turns, and to append membership activity. To log who added whom, read await ctx.table("$users").get(userId) for addedBy / addedBatchUserIds, then read $userInfo for display names and write a message such as "{name} added {usernames...}".

System Tables

These tables are automatically available in mutators and actions via ctx.table():

TableTypeDescription
$usersUserMembershipUsers currently in the store instance
$userInfoPoeUserInfoProfile information for each user
typescript
// In a mutator or action:
const user = await ctx.table("$users").get(userId);
const info = await ctx.table("$userInfo").get(userId);

_requestUploadUrl — app-controlled uploads

File uploads (store.files.upload() on the client) are off until your app opts in by declaring and implementing the reserved _requestUploadUrl action — the platform runs it server-side before minting each upload ticket, so it is your policy gate: who may upload, what, and when.

Declare it in the schema (the leading _ marks it platform-invoked; it never appears on the client's store.action):

typescript
actions: {
  _requestUploadUrl: {
    description: "Approve one photo upload per player per day",
    input: z.object({
      fileId: z.string(),
      sizeBytes: z.number(),
      name: z.string().optional(),
    }),
    output: z.unknown(),
  },
},

Implement it with your backend actions. The handler runs on a read-only action context (table reads and ctx.files.generateUploadUrl only — no ctx.mutate, no external dispatch). Approve by calling ctx.files.generateUploadUrl(...) exactly once; deny by throwing an ActionError whose code (letters/digits/_, bounded) reaches the client as error.appCode:

typescript
import { ActionError } from "poe-tiles-sdk/v1/backend.js";

const actions = {
  _requestUploadUrl: async (ctx, input) => {
    if (input.sizeBytes > 5 * 1024 * 1024) {
      throw new ActionError("photo_too_large");
    }
    await ctx.files.generateUploadUrl({ sizeBytes: input.sizeBytes });
    return null;
  },
};

Enforce app rules in the CLAIMING MUTATOR, not here. The handler is read-only and records nothing, so it cannot serialize concurrent uploads — any grant-time rule (one photo per day, one entry per round) is advisory at best, and the mutator that writes the fileRefs claim is the authoritative gate anyway (a rejected claim just lets the unclaimed upload expire). Gate here only what must be decided BEFORE the bytes are uploaded: the size cap, or whether uploads are open at all.

ctx.userId is the uploading member (platform-verified). generateUploadUrl accepts two distinct optional size params: sizeBytes re-asserts the request's declared size (a mismatch rejects the grant), while maxBytes is an approval CEILING the receiver enforces — not a way to pick the size; to enforce a size policy, throw before minting. Returning without minting (or minting twice) invalidates the grant. Platform quotas and rate limits still apply on top of your policy.

Backend actions can inspect the exact platform counters with ctx.files.getQuotaUsage(). It returns store-instance usage and byte/count limits plus the acting user's attributed usage. Pass { userId } to select another user in the same store instance:

typescript
const mine = await ctx.files.getQuotaUsage();
const anotherUser = await ctx.files.getQuotaUsage({ userId });
// { instance: { bytes, count, limitBytes, limitCount },
//   user: { userId, bytes, count } }

The call is allowed inside the read-only _requestUploadUrl handler, so an app can reject against exact aggregate usage before transferring bytes. The publish-time platform check remains authoritative under concurrent grants.

Platform Capabilities

Actions and guarded server-side mutator code have access to server-side services via ctx.platform.call() — AI streaming, hosted image generation, environment variables, and more. Prefer actions; mutator platform calls are discouraged, must be awaited, must be idempotent/read-only because optimistic-lock conflicts can retry the server mutator attempt, and should be rare because mutators are processed one at a time. See Platform for the full guidance.

typescript
const actions = {
  summarize: async (ctx, input) => {
    const stream = await ctx.platform.call("poe.modelProxy.stream", {
      model: input.model,
      request: { messages: [{ role: "user", text: input.prompt }] },
    });
    await stream.cancel();
  },
};

Calling a model from an action

An action is the only place an activity can reach a model. There is no client model API, so prompts, model ids and replies never enter your shipped bundle, and an activity can never hold a Poe credential. Two helpers, both from poe-tiles-sdk/v1/backend.js:

createModelStream for text — model is a model-proxy catalog id (discover ids with Poe.listModels() on the client and pass one in, or pin one), not a Poe bot handle:

typescript
import { createModelStream } from "poe-tiles-sdk/v1/backend.js";

const actions = {
  generateQuestions: async (ctx, input) => {
    const openModelStream = createModelStream(ctx.platform);
    let text = "";
    for await (const delta of openModelStream({
      model: input.model,
      prompts: [{ role: "user", text: "Five trivia questions about space." }],
    })) {
      text += delta.text;
    }
    // ...validate and write results via tables
  },
};

A failed round throws ModelStreamError (with a code and a retryable hint) rather than ending the loop quietly, so one try around the for await catches every failure mode. Types: ModelMessage, ModelStreamErrorCode — both from poe-tiles-sdk/v1/backend.js.

Most actions want the whole reply rather than the deltas — to parse JSON from it, validate it, or store it. collectModelText is that case, so you don't hand-roll an accumulator:

typescript
import { collectModelText, createModelStream } from "poe-tiles-sdk/v1/backend.js";

const openModelStream = createModelStream(ctx.platform);
const text = await collectModelText(
  openModelStream({ model: input.model, prompts: "Five trivia questions." }),
);

Keep your own for await loop only when you need the deltas as they arrive (rendering progress, counting chunks).

createImageGenerator for images. It resolves a served URL, not bytes — the platform re-hosts the generated image for you, so the result is something you can put straight in a store row and render:

typescript
import { createImageGenerator } from "poe-tiles-sdk/v1/backend.js";

const actions = {
  drawScene: async (ctx, input) => {
    const generateImage = createImageGenerator(ctx.platform);
    const { imageUrl } = await generateImage({
      model: "gpt-image-2",
      prompt: input.prompt,
    });
    return { imageUrl };
  },
};

Model calls belong in actions, not mutators (mutators must stay deterministic).

Type Exports

Type Inference Utilities

typescript
import type {
  InferActionContext,       // Action context type from schema
  InferActionHandlers,      // Action handler types from schema
  InferMutationContext,     // Mutation context type from schema
  InferMutatorHandlers,     // Mutator handler types from schema
  InferReadContext,         // Read-only context type from schema
  InferSchemaTableTypes,    // Table types from schema
  InferSchemaActionSchemas, // Action input schemas from schema
  InferSchemaMutatorSchemas,// Mutator input schemas from schema
} from "poe-tiles-sdk/v1/backend.js";

Core Types

typescript
import type {
  ActionContext, ActionFn,      // Server-side action context
  MutationContext, MutationFn,  // Shared mutation context (client + server)
  QueryContext, QueryFn,        // Query context
  JSONValue,                    // JSON-serializable value
  ScanResult,                   // Table scan result
  TableReader, TableWriter,     // Table operation interfaces
} from "poe-tiles-sdk/v1/backend.js";

Poe Platform Types

typescript
import type {
  PlatformCaller,    // Type for ctx.platform.call()
  PlatformAPI,       // Platform capability types
  PlatformAPIName,   // Platform capability names
  SystemHookMap,     // Typed hook definitions
  SystemTableTypes,  // System table type map
  PoeUserInfo,       // User profile data
  UserMembership,    // User membership record
} from "poe-tiles-sdk/v1/backend.js";

Migration Types

typescript
import type {
  AnyMigration,  // Single migration step
  Migrations,    // Array of migrations for schema versioning
} from "poe-tiles-sdk/v1/backend.js";