Guides
Embed WorkerDeck in your app
Put a sandboxed agent inside your own product - gateway in your server, sessions owned by your users, your data reached over MCP.
This is the guide for putting an agent inside a product you already have: your server, your login, your users, your data. The agent is a feature of your app, not a tool your operators run.
If instead you want to point a UI at a gateway somebody else runs, you want Embedding the UI - a different, smaller job.
The reference implementation for everything below is
apps/embedded: a wiki SPA
whose right-hand rail is a sandboxed agent, with the gateway inside the app’s own server. Every
step here is something it needed, in the order it needed it.
1. What you are building, and why it is one port
:PORT ─┬─ /v1/* the WorkerDeck gateway: REST + the session WebSocket
└─ everything else, through the gateway's `fallback`:
├─ /api/* your app's own API
├─ /mcp your data, as an MCP server, for the agent only
└─ /* your SPA
Decide this first, because it determines the whole layout: the gateway and your app must share an origin.
The tab drives a session over a WebSocket. A browser cannot put an Authorization header on a
WebSocket upgrade - the API simply has no such argument - so the only credential a tab can present
on an attach is a cookie, and a cookie is per-origin. Put the gateway on :8081 and your app’s
login stops authenticating the agent socket.
So the gateway takes the port and everything else rides its fallback. Nothing is proxied, and
there is no second server to forward upgrades to.
2. Mount the gateway in your server
fallback is a plain Node (req, res) handler, which is exactly what an Express app is:
import { createWorkerServer } from '@workerdeck/server'
const app = express() // your API, your static SPA - unchanged
app.get('/api/docs', …)
const worker = createWorkerServer({
fallback: (req, res) => { app(req, res) },
profiles: [/* §5 */],
authenticate: /* §3 */ undefined,
})
await worker.listen(8788, '127.0.0.1')
Checkpoint: GET /v1/profiles answers from the gateway, GET / answers from your app.
3. Authenticate with your app’s own session
authenticate receives the raw request and returns a principal - any object you like - or
null to reject with 401. It is called for REST calls and for WebSocket upgrades, so this is the
one place your app’s login meets the gateway.
authenticate: (req) => {
const user = myCookieAuth.resolve(req) // your existing session cookie
if (!user) return null
return {
scope: { user: user.id }, // §4 - the whole ownership model
allowedProfiles: ['wiki-agent'], // this principal may use exactly this profile
}
},
Three fields on the principal are read by the gateway; everything else is yours and comes back to your hooks verbatim.
| Field | Governs |
|---|---|
scope |
which sessions this principal can see or touch (§4) |
allowedProfiles |
which profiles it may create sessions on |
operator |
access to gateway-wide surfaces (/fs/*, /queue, /sdk-sessions) |
Do expensive work here - a database lookup, a token exchange - and hang the result on the principal. It runs once per request; the authorization predicate in §4 runs per row.
Checkpoint: a request without your cookie gets 401.
4. Ownership, without writing ownership code
scope is opaque string tags, assigned at create, immutable afterwards. The gateway stores and
enforces them; you decide what they mean. “user” is one app’s vocabulary; the next app’s is
“tenant” or “workspace”.
The default rule is: every key the principal pins must match. So scope: { user: 'alice' } sees
alice’s sessions and nothing else - enforced at the session routes, the list, the WebSocket attach
(before the wake, so nobody rebuilds a runner for a caller about to get a 404), deferred-execution
results, and the job routes.
Which means your client does no ownership filtering at all:
const { sessions } = await client.listSessions() // already only this user's
That is the point. A check the client performs is a check the client can skip. If the default rule
is not yours, supply authorizeSession(principal, session) - but keep it synchronous, because it
runs for every row of every list.
Two things worth internalising:
- A miss is 404, never 403. Whether a session exists in someone else’s scope is not this caller’s business, and the answer is byte-identical to an unknown id.
- Visibility is full control. There is no read-only attach: a client that can see a session can
message it, answer its approvals, interrupt it and close it. Scope is the boundary;
readOnlyon the panel is an affordance.
Checkpoint: user B gets 404 on user A’s session id.
5. Give the agent a model
An embedded agent almost always wants the provider engine - any AI SDK model, no CLI subprocess, no host filesystem - under a profile whose floor is nothing:
import { sandboxedProviderProfile } from '@workerdeck/server'
const profile = sandboxedProviderProfile(
'wiki-agent',
{ id: 'openai', model: 'gpt-5.6-luna', models: ['gpt-5.6-luna'], apiKeyEnv: 'OPENAI_API_KEY' },
{
instructions: SYSTEM_PROMPT,
capabilities: ['web_fetch'], // raised from nothing, deliberately, one at a time
mcpServers: ['wiki'], // §7
},
)
capabilities: [] and mcpServers: [] mean nothing; leaving them absent means “whatever the
host wired”. The empty arrays are load-bearing - do not normalise one into the other.
Then build the runner. createProviderRunner handles the four obligations that are invisible in
the types (forward restore, adopt id, seed the VFS only when not restoring, dispose on close):
import { createProviderRunner } from '@workerdeck/server'
createEngineRunner: (ctx) =>
createProviderRunner(ctx, {
model: (id) => openai(id ?? 'gpt-5.6-luna'),
executor: quickjs, // §6
capabilities: { webFetch: {} }, // backends; the profile decides the grants
seedVfs: { '/README.md': 'scratch space' },
}),
Note the split: the hook wires backends, the profile grants them. Wiring a backend only makes it offerable.
Checkpoint: a session runs a turn and streams tokens.
6. Decide what it may reach
sandboxedProviderProfile() starts at: an in-memory scratch filesystem, and eval_script in a
QuickJS guest with no network. No shell, no host files, no egress. Raise it one grant at a time,
and know what each costs:
| Grant | Gives the agent | Costs you |
|---|---|---|
web_fetch |
read a public URL, digested by the session’s model | egress, and page text entering the context as untrusted input |
web_search / download |
whatever backend you wire | the same, plus your search provider’s bill |
deliver_file |
hand a scratch file to the user as a download | a surface your app must actually have |
| an MCP server | your data (§7) | exactly what that server exposes |
Where sandboxed code runs is a real decision, not a default - the trade is laid out in Engines and executors. The short version: run it in-process when the data the loop reasons over is in your database; bridge it to the tab when the data is there and you would rather not receive it.
Checkpoint: eval_script computes something; no host path is reachable by any tool.
7. Give it your data over MCP
You can hand the engine a plain ToolSet and the agent cannot tell the difference. Prefer a real
MCP server anyway: it is the seam you actually have (your tools are usually already an MCP server,
or want to be reachable by other clients), and it keeps identity off the model.
const { token, revoke } = mintSessionToken(ctx.config.scope.user)
const mcp = await connectMcpTools(
{ wiki: { type: 'http', url: MCP_URL, headers: { authorization: `Bearer ${token}` } } },
{ required: true },
)
return createProviderRunner(ctx, { …, mcp, onClose: async () => { revoke(); await mcp.close() } })
Four rules here, each of which cost the reference app real time:
- Identity rides the transport, never a tool argument. A
userIdparameter is something the model can choose. A per-session bearer token minted increateEngineRunneris not. required: true. Without it a failed connect produces a session that reports perfectly healthy and quietly has no tools - the agent apologises its way through every request that needed them, with one line in a log nobody is reading. Hand the connection over asmcp(not justmcp.tools) and a profile naming a server that didn’t connect refuses to build.- A stateless MCP server must answer
GETwith 405. The client opens the SSE stream with aGETbefore it sends anything; under a framework’s default 404 the whole connect fails with an error naming neither the method nor the route. - Dispose in
onClose- which also runs when a session parks. A token that outlives its session is a credential nobody is tracking.
Checkpoint: GET /v1/sessions/:id/mcp lists your server as connected, with its tools.
One action set, two callers
If your app’s own API and your agent’s tools are the same operations - and they usually are - write
them once. The reference app does exactly this with
silkweave: an operation is a name, a Zod schema and a function, and
two adapters project the same set onto MCP (the agent) and tRPC (the SPA, typed end to end with no
codegen). At five operations that is tidy; at fifty it is the difference between one implementation
and two that drift - the app had write_doc and PATCH /api/docs/:id as one operation spelled
twice before this.
Three things make it work, and they generalise beyond any one toolkit:
- Identity resolves per adapter and lands in the same place. The agent arrives with a
per-session bearer token; the browser arrives with your login cookie. Both must become the same
thing by the time the function runs, so the function cannot tell - and must not care - which
caller it is serving. In silkweave that is the context’s
authkey, set by the MCP mount and bytrpcNode’sauthenticatehook. - Mount, don’t bind. An adapter that starts its own server puts your API on another origin and
breaks §1. You want the one that hands back a handler for the server you already have
(
trpcNode(), as of silkweave 5.1.0). - A shared set is not an identical one. Keep the operations where the two callers genuinely
differ on one side only -
whoamiand “navigate the user to this document” are agent tools; your SPA knows what it is showing.
One cost to budget for: a cookie-authenticated RPC endpoint is CSRF-able, and that guard is
yours. Check Sec-Fetch-Site (falling back to Origin) before you resolve the cookie, and
decline rather than throw so a forged request gets a plain 401 that explains nothing.
8. Put the panel in your UI
<SessionPanel
client={client} // ONE client per gateway
key={sessionId} // remount by key; never by moving it in the tree
sessionId={sessionId}
transcriptVariant="terminal" // the CLI's own form - right for a narrow rail
controlsSurface="status" // model + mode into the panel's own status bar
onVitals={(v) => setVitals(v)} // live readings for your own chrome
/>
baseUrl: '/v1' - same origin, so the cookie rides both the REST calls and the socket, and there
is no key in the tab.
The panel owns the session’s one attach. Anything else that wants live values reads them
through onVitals and changes them through onControls; opening a second attach means the
server’s tool bridge may ask the wrong client. And do not let the panel’s position in the tree
change: a remount drops the attach and the whole transcript.
9. App state and navigation: keep it yours
“Which document am I looking at” and “open that one for me” look like they want the tool bridge. They don’t, for two reasons:
- a bridged tool is by definition
sandboxed, and - the bridge asks the first attached client - with two tabs open, an arbitrary one answers.
Hold that state server-side, per user, and stream intents back down (SSE is plenty). Every tab then
agrees, and an agent working while the tab is shut still leaves the user in the right place. Report
honestly when nothing was listening - shown: false beats claiming a navigation that didn’t happen.
This is app architecture, not a missing feature, and it is the one thing on this page WorkerDeck should not solve for you.
10. What breaks in production
-
Restarts. Two options, and you need both:
parking: { store: createFileSessionStore({ dir: '…' }), persistLive: true }A durable store alone is not enough, and this is the trap:
park()only fires for a session resting on deferred executions, so an ordinary conversation never parks and a store on its own saves nothing.persistLiveis what covers the ordinary case - the runner’s snapshot is written through after every turn, and a restart rebuilds from it lazily, on first attach. It is off by default because it writes the session’s whole transcript to that store, in plaintext.This is the provider engine’s mechanism specifically. Claude and codex have engine-side session stores and go dormant instead, remembering only an id to resume from.
-
Restarts also sign people out, if you let them. A per-process session secret is the obvious default and it makes working persistence look completely broken: sessions are scoped, a scoped session answers 404 to anyone else, so a new secret on boot preserves every conversation and leaves every one unreachable. Persist whatever signs your cookies.
-
More than one pod. Sessions are in-process. A second instance behind a load balancer does not see the first one’s sessions, and the WebSocket must land on the pod that owns the runner. Pin by session id, or run one instance, until you have a story here.
-
Two tabs. Both can attach; both can send. The bridge asks the first. Decide whether that is fine (it usually is) or whether your UI should say so.
-
Prompt injection through tool results. A fetched page, a document another user shared, an MCP result - all of it is untrusted input that reaches the model as text. Grants are the mitigation: an agent that cannot delete cannot be talked into deleting. Say so in the system prompt too, but do not rely on it.
-
A destructive tool with no approval channel. The provider engine’s capability record says
interactiveApprovals: false- there is nothing to gate a delete behind. The honest options are to grant it or not.