Yoke.sh
Concepts

Portals

Yoke portals — singleton ingress and egress, HTTP routes, crons, and typed interfaces.

Portals connect the Yoke runtime to integrations it needs (Slack, GitHub, third-party APIs). They are not actors — they have no queues, no filters, and no reactive process() handler.

Your product's public HTTP belongs on your server (the embed host), not on a portal. Portal routes should stay private. See Embed Yoke in your product.

Every portal definition has exactly one singleton instance per tenant at runtime boot.

What portals do

  • Register HTTP/WebSocket routes on the runtime server
  • Run cron-scheduled route invocations via crons (wake-on-tick, not long-running loops)
  • Append events to the global stream (ingress)
  • Optionally publish a typed interface callable from actors (egress)

What portals do not do

  • Have queues or process stream events reactively
  • Filter or subscribe to the stream
  • Scale to multiple instances per definition

"Chat channel" or "Slack integration" are conventions built with portals — not kernel types. Portal patterns emerge from code, not from a type hierarchy in core.

Portal shape

definePortal<SlackApi>({
  configSchema: z.object({ botToken: z.string() }),

  interface: {
    async sendMessage(channel: string, text: string) {
      await fetch("https://slack.com/api/chat.postMessage", {
        method: "POST",
        headers: { Authorization: `Bearer ${ctx.config.botToken}` },
        body: JSON.stringify({ 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: body,
        });
        return { status: 200, body: "ok" };
      },
    },
  ],
});

Ingress — append to stream

Portals turn external signals into stream events. The webhook handler above is the most common pattern:

HTTP POST /webhooks/slack
    → portal route handler
    → ctx.append({ type: "slack.message.received", ... })
    → Postgres stream_events
    → async fan-out to actor queues

crons on portal definitions are another ingress path — scheduled route invocations that append heartbeat or polled data events.

Egress — typed interface

Actors call portal methods through ctx.portals.<name>.*:

async process(ctx, event) {
  await ctx.portals.slack.sendMessage("#general", "Task complete");
}

Under the hood: SDK → portal_call op → Rust → Deno portal_invoke → your interface method.

The interface is typed through defineFor(events, portals) — ctx.portals.slack knows sendMessage's signature.

HTTP routes

Portal routes mount on the runtime HTTP server alongside system endpoints:

EndpointOwner
GET /healthRuntime
GET /metricsRuntime (Prometheus)
Portal routesUser-defined

Route handlers receive (req, ctx) where ctx is PortalCtx — append, instances, conversations, schedules, but no state/sqlite (portals are stateless by convention; use actor state for durable portal-adjacent data).

Path parameters

{
  method: "POST",
  path: "/agents/:agentId/join",
  handler: async (req, ctx) => {
    const agentId = req.path.split("/")[2];
    // ...
  },
}

Register agent bootstrap at /agents and /agents/:agentId/join on an executor portal — not /rooms.

Portal context

PortalCtx provides:

APIAvailable
ctx.configPortal instance config
ctx.append()Stream ingress
ctx.instancesCreate actor instances
ctx.conversationsConversation store
ctx.schedulesKeyed delayed events
ctx.correlationEvent correlation

Portals do not have ctx.state or ctx.sqlite — they are singleton connectors, not stateful processors.

Boot provisioning

Use defineRuntime().bootstrap(fn) to provision fixed actor topology once at boot (idempotent by instance id):

export default defineRuntime()
  .events(events)
  .bootstrap(async (ctx) => {
    await ctx.instances.create("router", {
      id: "router",
      config: { channels: ["#general"] },
      filter: { type: "slack.message.received" },
    });
  })
  .portals(/* ... */)
  .actors(/* ... */)
  .build();

The runtime ensures portal singleton rows exist in Postgres, runs bootstrap() if defined, then registers portal routes and crons.

Portals do not support long-running start() hooks. Use bootstrap() for instance provisioning and portal crons or HTTP routes for recurring ingress.

Multi-tenant ingress

In hosted deployments, portal HTTP may arrive at a tenant executor after the control plane scheduler wakes a cold runtime. The route dispatch path is the same — PortalHttp activity signals keep the tenant hot.

Control plane HTTP (conversations, blobs, schedules) runs on port 8090 by default — separate from the executor portal port.

Design patterns

Register POST routes that parse request bodies and ctx.append() typed events. Common for Shopify, Stripe, and other webhook providers.

On this page