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 viactx.portals.<name>.<method>()routes— HTTP ingress handlers that append events or run side effectscrons— 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
| Field | Required | Description |
|---|---|---|
configSchema | no | Zod schema for portal instance config |
interface | no | API object — methods invoked via portal_call RPC |
routes | no | HTTP route table |
crons | no | Recurring 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
| Portal | Actor | |
|---|---|---|
| Instances | One singleton per definition | Many dynamic instances |
| State | No ctx.state / ctx.sqlite | Full durable state |
| Ingress | HTTP routes | Event filters only |
| Egress | interface methods | ctx.portals.* |
| Queue | None | Persisted 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.