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 queuescrons 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:
| Endpoint | Owner |
|---|---|
GET /health | Runtime |
GET /metrics | Runtime (Prometheus) |
| Portal routes | User-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:
| API | Available |
|---|---|
ctx.config | Portal instance config |
ctx.append() | Stream ingress |
ctx.instances | Create actor instances |
ctx.conversations | Conversation store |
ctx.schedules | Keyed delayed events |
ctx.correlation | Event 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.