Yoke.sh
SDK

Portals

definePortal, HTTP routes, portal crons, and typed interface egress from actors.

A portal is a singleton definition (one instance per portal name at runtime boot) that bridges integrations the runtime needs (Slack, GitHub webhooks, third-party APIs) and the event stream.

Your product's public HTTP is not a portal — it is your host server, which embeds Yoke and calls append / conversation_ops. Keep portal routes private. See Embed Yoke in your product.

Portals expose:

  • interface — typed methods actors call via ctx.portals.<name>.<method>()
  • routes — HTTP ingress handlers that append events or run side effects
  • crons — scheduled invocations of a portal route with a JSON payload (wake-on-tick, not long-running loops)

Portals do not support long-running start() hooks. The compute model wakes runtimes for HTTP ingress, stream events, and scheduled ticks — then goes cold.

definePortal

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

const events = defineEvents({
  "slack.message.received": z.object({
    channel: z.string(),
    text: z.string(),
    user: z.string().optional(),
  }),
});

const slackPortal = definePortal({
  events,
  configSchema: z.object({ botToken: z.string() }),

  interface: {
    async sendMessage(channel: string, text: string) {
      // call Slack API using bot token
    },
  },

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

Inside defineRuntime().portals(), use defineFor(events).portal({ ... }).

PortalDefinition fields

FieldRequiredDescription
configSchemanoZod schema for portal instance config
interfacenoAPI object — methods invoked via portal_call RPC
routesnoHTTP route table
cronsnoRecurring route invocations with payload

Generic portal typing:

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

portal<SlackApi, typeof slackConfigSchema>({
  configSchema: slackConfigSchema,
  interface: { /* ... */ } satisfies SlackApi,
});

routes

Each route maps to runtime HTTP dispatch (portal_route_dispatch RPC):

type PortalRoute = {
  method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
  path: string;
  handler: PortalRouteHandler;
};

PortalRequest

type PortalRequest = {
  method: string;
  path: string;
  headers: Record<string, string>;
  body: Uint8Array | null;
};

PortalResponse

type PortalResponse = {
  status: number;
  headers?: Record<string, string>;
  body?: Uint8Array | string;
};

Handler signature

type PortalRouteHandler = (
  req: PortalRequest,
  ctx: PortalCtx,
) => Promise<PortalResponse> | PortalResponse;

Example webhook:

routes: [
  {
    method: "POST",
    path: "/v1/github",
    handler: async (req, ctx) => {
      if (req.headers["x-hub-signature"] !== expected) {
        return { status: 401, body: "invalid signature" };
      }
      const payload = JSON.parse(new TextDecoder().decode(req.body!));
      await ctx.append({
        type: "github.push",
        payload: { repo: payload.repository.full_name },
      });
      return { status: 202, body: JSON.stringify({ accepted: true }) };
    },
  },
],

At boot the worker returns all routes via list_portal_routes. The Rust runtime mounts them under the portal's configured base path.

crons

Declare periodic work as route invocations — the runtime wakes, dispatches the handler, and returns to idle:

crons: [
  {
    key: "poll-inbox",
    cadence: { kind: "every", everyMs: 60_000 },
    route: { method: "POST", path: "/internal/poll" },
    payload: { since: 0 },
  },
],

Each tick POSTs payload to the route handler (same as an HTTP request body). Use defineRuntime().bootstrap() for one-shot instance provisioning at cold start.

interface

The typed egress surface for actors. Each method becomes a JSON-RPC portal_call:

// In actor process():
await ctx.portals.slack.sendMessage("#deploys", "Shipped v2.1");

Implementation runs in the Deno worker against the portal singleton's interface object:

interface: {
  async sendMessage(channel: string, text: string) {
    const res = await fetch("https://slack.com/api/chat.postMessage", {
      method: "POST",
      headers: { Authorization: `Bearer ${botToken}` },
      body: JSON.stringify({ channel, text }),
    });
    if (!res.ok) throw new Error(await res.text());
  },

  async uploadFile(channel: string, bytes: Uint8Array, filename: string) {
    // ...
  },
},

Portal methods can close over module-level state or read config injected at boot. They do not receive PortalCtx automatically — use closures or module scope for credentials.

Cross-portal calls

Portals can call other portals through ctx.portals inside start and route handlers (proxy to other singleton interfaces).

Portal vs actor

PortalActor
InstancesOne singleton per definitionMany dynamic instances
StateNo ctx.state / ctx.sqliteFull durable state
IngressHTTP routesEvent filters only
Egressinterface methodsctx.portals.*
QueueNonePersisted per-instance queue

PortalRegistration

type PortalRegistration<Name, Api, TConfig, TPortals, TEventMap> = {
  readonly name: Name;
  readonly definition: PortalDefinition<...>;
};

InferPortalApi<R> extracts the interface type from a registration.

Runtime registration

export default defineRuntime()
  .events(events)
  .portals(({ events }) => {
    const { portal } = defineFor(events);
    return {
      slack: portal({ /* ... */ }),
      controlPlane: portal({
        routes: [{ method: "GET", path: "/health", handler: () => ({ status: 200, body: "ok" }) }],
      }),
    };
  })
  .actors(({ events, portals }) => {
    const { actor } = defineFor(events, portals);
    return {
      notifier: actor({
        configSchema: z.object({ channel: z.string() }),
        stateSchema: z.object({}),
        async process(ctx, event) {
          await ctx.portals.slack.sendMessage(ctx.config.channel, "ping");
        },
      }),
    };
  })
  .build();

The .portals() callback types portals.slack inside .actors() from the registered interface.

On this page