Yoke.sh
Getting Started

Quick Start

Create a minimal package, start the runtime, append a test event, and watch actors process it end to end.

This walkthrough uses a minimal inline package — a Slack portal, a router actor, and a coder actor — so you can see stream fan-out without writing a host yet. Production still means embedding the kernel in your server; yoke dev is not the product interface.

By the end you will have a live runtime, an appended slack.message.received event, and actors that react through the global stream.

What your package does

The package wires three pieces together:

  1. Slack portal — HTTP webhook ingress and a typed sendMessage interface
  2. Router actor — listens for slack.message.received, emits work.assigned
  3. Coder actor — listens for work.assigned (function filter on lang), emits code.produced, calls ctx.portals.slack.sendMessage

Step 1 — Prerequisites

Start Postgres and set environment variables (see Installation):

docker compose up -d
export DATABASE_URL=postgres://yoke:yoke@localhost:5432/yoke
export YOKE_STORAGE=pg_sqlite

Install the yoke CLI (see Installation).

Step 2 — Create your package

Create a folder ./your-package with deno.json and yoke.ts:

// your-package/deno.json
{
  "imports": {
    "@yoke/sdk": "jsr:@yoke/sdk@^0.1.0"
  }
}
// your-package/yoke.ts
import { defineEvents, defineFor, defineRuntime, z } from "@yoke/sdk";

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

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 }) => {
    const { portal } = defineFor(events);
    return {
      slack: portal({
        configSchema: z.object({ botToken: z.string().optional() }),
        interface: {
          async sendMessage(channel, text) {
            console.log(`slack: ${channel} <- ${text}`);
          },
        },
        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" };
            },
          },
        ],
      }),
    };
  })
  .actors(({ events, portals }) => {
    const { actor } = defineFor(events, portals);
    return {
      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 }));
        },
      }),
      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,
        queue: { mode: "fifo", concurrency: 1 },
        async process(ctx, event) {
          await ctx.append({
            type: "code.produced",
            payload: { code: "// generated" },
          });
          await ctx.portals.slack.sendMessage("#general", "Code ready");
          await ctx.state.update((s) => ({
            ...s,
            jobsCompleted: s.jobsCompleted + 1,
          }));
        },
      }),
    };
  })
  .build();

See Your first package for a deeper walkthrough of each piece.

Step 3 — Start the runtime

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

(yoke start is an alias for yoke dev. For production compile/deploy, see Compile & deploy.)

On boot the runtime:

  1. Runs Postgres migrations
  2. Spawns the embedded Deno worker and loads yoke.ts
  3. Ensures portal singletons and runs bootstrap() (creates router/coder instances)
  4. Starts fan-out and actor drain loops
  5. Registers portal HTTP routes (including POST /webhooks/slack)

Leave this process running in a terminal. On success you should see the Deno worker load and the runtime reach a ready state (health check returns ok).

Step 4 — Append a test event

In a second terminal, use the CLI append subcommand:

yoke append \
  --database "$DATABASE_URL" \
  --storage pg_sqlite \
  --type slack.message.received \
  --payload '{"channel":"#general","text":"hello"}'

The command prints the assigned stream position:

appended seq=1 id=<uuid>

Alternatively, POST to the portal webhook while the runtime is running:

curl -X POST http://localhost:8080/webhooks/slack \
  -H 'Content-Type: application/json' \
  -d '{"channel":"#general","text":"hello from webhook"}'

Step 5 — Watch actors process

In the runtime terminal you should see processing unfold:

  1. Router receives slack.message.received (declarative filter from instance creation)
  2. Router appends work.assigned with role: "coder" and lang: "typescript"
  3. Coder matches via its function filter (filterFromConfig)
  4. Coder appends code.produced and calls ctx.portals.slack.sendMessage("#general", "Code ready")

Expected console output from the coder path:

slack: #general <- Code ready

Event flow diagram

sequenceDiagram
    participant CLI as CLI / Webhook
    participant Stream as Global Stream
    participant Router as router instance
    participant Coder as coder instance
    participant Slack as slack portal

    CLI->>Stream: append slack.message.received
    Stream->>Router: fan-out to queue
    Router->>Stream: append work.assigned
    Stream->>Coder: fan-out to queue
    Coder->>Stream: append code.produced
    Coder->>Slack: sendMessage("#general", "Code ready")

Inspect runtime state

EndpointPurpose
GET /healthLiveness check — returns ok
GET /metricsPrometheus metrics (yoke_stream_appends_total, yoke_queue_depth, …)

Check queue depth per instance:

curl -s http://localhost:8080/metrics | grep yoke_queue_depth

Stop the runtime

Press Ctrl+C in the runtime terminal. The runtime shuts down the Deno worker and flushes storage backends.

The append CLI command starts a minimal runtime, appends one event, and stops. For sustained processing while authoring, use yoke dev with your package directory. To ship, embed the kernel in your server.

Next steps

On this page