Yoke.sh
Getting Started

Your First Package

Structure a Yoke user package — yoke.ts, defineRuntime, defineActor, definePortal, and defineEvents.

A user package is a folder of TypeScript that exports a single defineRuntime() definition. The Yoke kernel loads it in an embedded Deno worker at boot, registers actor and portal definitions, and runs instances that react to events on the global stream.

This package is internal to your product. yoke compile --crate bakes it into a Rust library your server depends on — it does not take a filesystem path to yoke.ts. End users never run yoke.ts or @yoke/sdk — see Embed Yoke in your product.

This page walks through the anatomy of a minimal package — a Slack portal, a router actor, and a coder actor.

Package layout

Inside your product the TypeScript the kernel loads lives in the JS workspace. You compile it into a crate the rest of the Cargo workspace depends on — see Embed Yoke in your product.

your-product/
├── Cargo.toml              # Rust workspace — crates/cli, crates/server, crates/yoke
├── package.json            # JS workspace (Turborepo)
├── turbo.json
├── crates/
│   ├── yoke/               # generated by yoke compile --crate
│   ├── server/
│   └── cli/
└── packages/
    └── runtime/            # internal — yoke.ts the compiler bakes in
        ├── deno.json
        ├── yoke.ts
        ├── actors/
        └── portals/

While learning, you can author just the inner package folder (./your-package) without the full product workspace. Add the Cargo workspace when you embed.

The worker always loads yoke.ts at the package root (package_path). The default export must be a defineRuntime() builder (with or without .build()).

deno.json

Your package is a Deno project. Import @yoke/sdk from JSR and declare any npm dependencies:

{
  "imports": {
    "@yoke/sdk": "jsr:@yoke/sdk@^0.1.0"
  }
}

The embedded worker resolves deno.json, import maps, npm packages, and node: builtins when the runtime starts.

defineEvents — typed event schemas

Events are the only communication channel between actors. Declare payload schemas once with Zod:

import { defineEvents, z } from "@yoke/sdk";

const events = defineEvents({
  "slack.message.received": z.object({
    channel: z.string(),
    text: z.string(),
    user: z.string().optional(),
  }),
  "work.assigned": z.object({
    text: z.unknown().optional(),
    role: z.string(),
    lang: z.string().optional(),
  }),
  "code.produced": z.object({
    code: z.string(),
  }),
});

defineEvents() returns a typed event map used by ctx.append() and process() for compile-time payload narrowing. The worker validates payloads at append time against these schemas.

defineRuntime — the builder

defineRuntime() is a fluent builder that registers everything the kernel needs:

export default defineRuntime()
  .events(events)
  .bootstrap(async (ctx) => {
    await ctx.instances.create("router", {
      id: "router",
      config: { channels: ["#general"] },
      filter: { type: "slack.message.received" },
    });
    await ctx.instances.create("coder", {
      id: "coder",
      config: { model: "claude-sonnet", lang: "typescript" },
    });
  })
  .portals(({ events }) => { /* ... */ })
  .actors(({ events, portals }) => { /* ... */ })
  .build();
MethodPurpose
.events(map)Register Zod schemas per event type
.bootstrap(fn)One-shot boot hook — provision instances with stable ids
.portals(fn)Register portal definitions (singleton ingress/egress)
.actors(fn)Register actor definitions (reactive processors)
.agents(fn)Register high-level agent definitions (expands to actor stack)
.chatPlatforms(fn)Register chat platforms (expands to portal + bridge actor)
.inferenceStack(stack)Optional LLM routing for agent processors
.build()Produce the RuntimeDefinition the worker loads

defineFor — scoped definitions

Inside .portals() and .actors(), use defineFor(events) or defineFor(events, portals) so event types and portal interfaces flow into actor process() handlers without repeating the events field:

.portals(({ events }) => {
  const { portal } = defineFor(events);
  return { slack: portal({ /* ... */ }) };
})
.actors(({ events, portals }) => {
  const { actor } = defineFor(events, portals);
  return {
    router: actor({ /* ... */ }),
    coder: actor({ /* ctx.portals.slack is typed */ }),
  };
})

definePortal — connect to the outside world

Portals are not actors. They connect the runtime to external systems — HTTP webhooks, WebSockets, polling loops — and optionally expose a typed interface for actors to call:

type SlackApi = {
  sendMessage(channel: string, text: string): Promise<void>;
};

slack: portal<SlackApi, typeof slackPortalConfigSchema>({
  configSchema: z.object({ botToken: z.string() }),

  interface: {
    async sendMessage(channel, text) {
      console.log(`slack: ${channel} <- ${text}`);
    },
  } satisfies SlackApi,

  routes: [
    {
      method: "POST",
      path: "/webhooks/slack",
      handler: async (req, ctx) => {
        const body = JSON.parse(new TextDecoder().decode(req.body ?? new Uint8Array()));
        await ctx.append({
          type: "slack.message.received",
          payload: {
            channel: body.channel ?? "#general",
            text: body.text ?? "",
          },
        });
        return { status: 200, body: "ok" };
      },
    },
  ],
}),

Portals have no queues and no filters. They append events to the stream (ingress) and expose methods actors call via ctx.portals.<name>.* (egress).

defineActor — reactive event processors

Actors are stateful, queued handlers. Each definition requires configSchema, stateSchema, and process:

router: actor({
  configSchema: z.object({ channels: z.array(z.string()) }),
  stateSchema: z.object({ lastSeq: z.number() }),
  queue: { mode: "fifo", concurrency: 1 },

  async process(ctx, event) {
    if (event.type === "slack.message.received") {
      await ctx.append({
        type: "work.assigned",
        payload: {
          text: event.payload.text,
          role: "coder",
          lang: "typescript",
        },
      });
    }
    await ctx.state.update((s) => ({ ...s, lastSeq: event.seq }));
  },
}),

Function filters

When an instance needs logic beyond declarative filters, provide filterFromConfig:

coder: actor({
  configSchema: z.object({ model: z.string(), lang: z.string() }),
  stateSchema: z.object({ jobsCompleted: z.number() }),

  filterFromConfig: (config) => (event) =>
    event.type === "work.assigned" && event.payload.lang === config.lang,

  async process(ctx, event) {
    await ctx.append({ type: "code.produced", payload: { code: "// ..." } });
    await ctx.portals.slack.sendMessage("#general", "Code ready");
    await ctx.state.update((s) => ({
      ...s,
      jobsCompleted: s.jobsCompleted + 1,
    }));
  },
}),

Function filters are not serializable. On restart, the runtime rebuilds them from persisted instance config via filterFromConfig. Without it, function-filter instances survive in Postgres but receive no events until recreated.

Context inside process()

Inside process(), ctx provides the full runtime surface:

APIPurpose
ctx.configInstance config (validated against configSchema)
ctx.stateTyped durable document (get, set, update)
ctx.sqliteRaw SQL on the same per-instance database
ctx.append()Publish events to the global stream
ctx.portals.*Call portal interface methods
ctx.instances.create()Spawn new actor instances
ctx.correlationGroup related events into work chains
ctx.schedulesKeyed delayed events
ctx.conversationsConversation store CRUD (when enabled)

See Context API for the complete reference.

Loading the package

Production: yoke compile --crate -o crates/yoke bakes this folder into a library crate. Your CLI and server depend on that crate — they never take a filesystem path to yoke.ts. See embed.

Authoring: the CLI loads the same folder so you can iterate without compiling:

export DATABASE_URL=postgres://yoke:yoke@localhost:5432/yoke
export YOKE_STORAGE=pg_sqlite

yoke dev ./your-package \
  --database "$DATABASE_URL" \
  --storage pg_sqlite

The CLI boots Postgres migrations, loads your package in Deno, ensures portal singletons, runs bootstrap() if defined, and begins actor drain loops. It is a development host, not the product.

Create ./your-package with deno.json and yoke.ts (see Quick start for a full inline example).

Edit yoke.ts — add events, actors, and portals.

Add .bootstrap() to create instances programmatically with stable ids.

Run yoke dev ./your-package and append test events with yoke append.

Next steps

On this page