Thread Access

src/thread-access.ts answers one question: may this caller create, read, mutate, or destroy this thread?

Middleware answers a different question ("may this caller run this route") keyed on a different thing. A thread has no owning route. Every endpoint that starts a turn overwrites the thread's route metadata, so any caller allowed to run any route on a thread can move it onto a route of their choosing. So thread authorization is keyed on the thread object, and lives in its own file with its own failure policy.

Without a policy file, every thread endpoint is open to anyone who can name a thread id. Ids are neither secret nor collision-proof (t- plus four random bytes).

The common case: ownedThreadsCopy link to section: The common case: ownedThreads

Most apps want one rule: each caller reaches only the threads it created. ownedThreads is that policy as a value:

src/thread-access.ts
import { ownedThreads } from "@b4run/sdk"
import type { Principal } from "./auth.js"
 
export default ownedThreads<Principal>({ adminsRead: (user) => user.isAdmin })

It stamps every created thread { ownerId } from the caller's principal and checks that stamp on every later request. An anonymous caller is denied, and so is a request for a thread with no row, ahead of any admin branch, so "not yours" and "never existed" are the same 404. adminsRead principals may also read every thread, and threads created before the app had a policy. They never update or delete another caller's thread. owner changes the id a thread is stamped with (an org instead of a user, say). The create-b4-app templates use it.

It is also the one policy shape a build target that can't run arbitrary policy code can recognize and translate. For anything more, write the policy yourself.

The shapeCopy link to section: The shape

Here's the same policy written out by hand: each caller reaches only their own threads, with read access for admins. It reads req.principal, the caller your src/auth.ts resolved for this request:

src/thread-access.ts
import { defineThreadAccess, deny, permit, type ThreadAccessRequest } from "@b4run/sdk"
 
const owned = (req: ThreadAccessRequest) => {
  const user = req.principal
  if (!user) return deny()
  // `thread: undefined` reaches `delete` too. Denying it FIRST, ahead of the
  // admin branch, is what keeps "not yours" and "does not exist" the same
  // answer; an admin allowed to delete a row that never existed reopens the
  // existence oracle this default closes.
  if (req.thread === undefined) return deny()
  const owner = req.thread.access?.ownerId
  if (owner === undefined) return user.isAdmin ? permit() : deny() // legacy thread
  if (owner === user.id) return permit()
  if (req.action === "read" && user.isAdmin) return permit()
  return deny()
}
 
export default defineThreadAccess({
  create: (req) => {
    const user = req.principal
    return user ? permit({ ownerId: user.id, org: user.org }) : deny()
  },
  // Also handles the post-create `update` recheck: the row just stamped has
  // `ownerId === user.id`, so `owned` permits it; a row the store handed back on
  // an id collision carries someone else's, so `owned` denies and the caller
  // never receives a thread they do not own.
  fallback: owned,
})

B4.run probes four paths, in order: src/thread-access.ts, src/thread-access.js, thread-access.ts, thread-access.js. The default export wins, and a named threadAccess export is the fallback.

user.isAdmin and user.org type-check because b4 typegen declares the principal type your authenticate returns (see Authentication).

The basic template of create-b4-app scaffolds this file, and the src/auth.ts that resolves its principal, as src/thread-access.ts.example and src/auth.ts.example at the generated root. Drop both .example suffixes to activate them. B4.run only probes the names above, so an unrenamed scaffold changes nothing. They ship inert because a deny-by-default policy denies every caller the app can't authenticate yet, and a generated app has no identity provider on its first run. The navlog template ships them active in its server/ package: its src/auth.ts resolves one local principal when no proxy token is configured, so local development is unaffected, and each visitor is its own principal behind the deployed proxy.

fallback is required. That makes "I forgot to handle delete" a compile error. Without it, every request for that action would be silently allowed (or denied).

What the policy receivesCopy link to section: What the policy receives

FieldNotes
action"create", "read", "update" or "delete": which handler was selected.
operationThe specific endpoint, e.g. "thread.state", for policies that need finer grain than action.
threadIdundefined only on POST /threads, whose id is server-generated.
threadThe stored row, or undefined when no row exists.
principalThe caller, as src/auth.ts resolved it for this request. undefined when the app has no auth file or the request is anonymous. There is no headers field: identity comes from the one resolver.
method, urlThe originating request's method, and its path plus query.
requestedMetadataClient-supplied metadata on a create, already stripped of B4.run's reserved key. undefined everywhere else.
requestedWorkspaceThe workspace the request stages or chooses: { sourceDigest } on workspace.source.put, the whole reference { sourceDigest, environmentLinks?, baseline? } plus uploadedBy on a thread.create that names one. undefined everywhere else, including the create's recheck.
resumingtrue when this request carries a resume credential or a client tool result, and may continue a parked turn. Always a boolean. See below.

resuming: which requests continue a parked turnCopy link to section: resuming: which requests continue a parked turn

resuming is true when the request answers a parked human-approval prompt — it carries an interruptId/resumeKey credential and continues an already-interrupted run instead of starting a fresh one — or when an AG-UI request ends in a role: "tool" message, which is how a client answers a parked client-provided tool call. It's false on every other request and never absent, so a policy writes if (req.resuming) and never ?? false.

The trailing tool message counts on every AG-UI route, whether or not the route takes client tools, and whether or not a call is actually parked: the policy runs before B4.run reads the thread, so resuming describes the request, not the thread. It can therefore be true on a request that turns out to resume nothing — the safe direction for a rule that holds resumes to a higher bar.

Both doors resume. Two different endpoints continue a parked turn, and only one of them says so in its operation:

Requestoperationresuming
POST /threads/:thread_id/resumerun.resumealways true
POST /agui/{routeId} carrying a resume arrayrun.aguitrue
POST /agui/{routeId} whose last message has role: "tool"run.aguitrue
POST /agui/{routeId} with neitherrun.aguifalse
everything elseanyfalse

The middle rows are why this field exists. An AG-UI resume reports run.agui, just like an ordinary AG-UI turn. Only the request body tells them apart, and a policy never sees it. So a policy that holds resumes to a higher bar (step-up auth, a second approver, extra logging) must check req.resuming, not req.operation. Keying that rule on operation === "run.resume" leaves every CopilotKit-driven resume ungoverned.

operation is endpoint identity ("which door did this come through"). resuming is request shape ("what is in the body"). Neither replaces the other.

An endpoint that gates more than once for a single request (the gate before its side effects, the mid-flight recheck, the implicit create's recheck) reports the same resuming at every one of them.

ts
import { defineThreadAccess, deny, permit } from "@b4run/sdk"
 
export default defineThreadAccess({
  // ...
  update: (req) => {
    if (!owns(req)) return deny()
    // Resuming a parked approval is the moment the agent gets to act, so it
    // costs a fresh step-up on BOTH doors, which `operation` alone cannot see.
    if (req.resuming && !hasStepUp(req.principal)) return deny({ status: 403 })
    return permit()
  },
  fallback: (req) => (owns(req) ? permit() : deny()),
})

thread.metadata is client-supplied and untrusted. Anyone who can create a thread can write anything into it. Authorize against thread.access, which is the stamp your own create decision returned. B4.run stores it under a reserved key (b4:access) that it strips from client input on every create path, so a client can't forge one, and it's lifted out of metadata before your policy sees the row.

thread.access is undefined for a thread created before you adopted a policy. B4.run leaves that case to you. The two sane answers are admin-only (the owned example above) or a one-time backfill (below).

A policy never parses a header. Who the caller is comes from src/auth.ts, resolved once per request before any gate, so the policy and middleware can't disagree about it. See Authentication for how to compare headers there.

Workspace endpointsCopy link to section: Workspace endpoints

sandbox.workspaceRead: "http" serves a read of a thread's workspace files as the thread.workspace operation, under read. B4.run refuses to boot the option without a policy. A policy that authorizes by owner already covers it; a service that reads every thread (a controller, a reviewer) is usually admitted as its own principal, which src/auth.ts resolves from a credential compared in constant time.

sandbox.stagedWorkspaces serves PUT /workspace/sources/:digest as the workspace.source.put operation, a create with no threadId and no thread, and accepts a workspace on POST /threads. Both carry requestedWorkspace: the upload its { sourceDigest }, the create the whole reference it names. Enabling the option means reviewing your create handler: one written before it admits both unless it checks this field. One rule, if (req.requestedWorkspace), decides who may stage and choose a workspace, and lets a caller create threads without choosing what they run on. B4.run refuses to boot the option without a policy.

A stamp your policy returns on workspace.source.put is not a thread's: B4.run keeps it with the upload as its uploader, and a create that names that source carries every such stamp in requestedWorkspace.uploadedBy. Uploads are content-addressed, so two callers who upload the same bytes share one source and both appear. To keep one caller from choosing another's upload, stamp uploads with the caller and check it on create:

src/thread-access.ts
export default defineThreadAccess({
  create: (req) => {
    const user = req.principal
    if (!user) return deny()
    // The stamp on an upload is kept as its uploader, not as a thread's.
    if (req.operation === "workspace.source.put") return permit({ ownerId: user.id })
    const uploadedBy = req.requestedWorkspace?.uploadedBy
    if (uploadedBy && !uploadedBy.some((uploader) => uploader.ownerId === user.id))
      return deny({ status: 403 })
    return permit({ ownerId: user.id, org: user.org })
  },
  fallback: owned,
})

owned is the one from the shape above. B4.run compares uploaders by their JSON with keys sorted, so a stamp built in a different key order is the same uploader. A stamp that is not a JSON object of at most 16 KiB cannot be kept, and the upload is refused with 422 workspace_uploader_invalid before its body is read.

DenialsCopy link to section: Denials

deny() produces 404 for a read, 403 for everything else.

EndpointDefault deny
POST /threads403 thread_access_denied
GET /threads/:thread_id404, the same body a genuine miss returns
GET /threads/:thread_id/state404, the same body a missing checkpoint returns
DELETE /threads/:thread_id403 thread_access_denied
POST /threads/:thread_id/cancel403 thread_access_denied
GET /threads/:thread_id/pending_interrupts404 thread_not_found, the same body a genuine miss returns
GET /threads/:thread_id/turns404 thread_not_found, the same body a genuine miss returns
GET /threads/:thread_id/events404 thread_not_found, the same body a genuine miss returns
POST /threads/:thread_id/runs/stream403 thread_access_denied
POST /threads/:thread_id/runs/wait403 thread_access_denied
POST /threads/:thread_id/resume403 thread_access_denied
POST /agui/:routeId403 thread_access_denied

The read default is 404 so a denial looks the same as a miss. That stops anyone enumerating thread ids. The 403s sit on endpoints where the caller has already named a specific thread and asked to change it.

deny({ status, body }) overrides both. status accepts only 403 or 404 (a policy can't mint a 200, a 500 or a redirect). Anything else falls back to the per-action default.

The policy runs on every gated request, including when the row is missing. On /state that matters for a second reason: the checkpointer is a separate store from the threads store, so a transcript can exist for a thread whose row is gone, and skipping the gate would serve it ungated.

Failure modesCopy link to section: Failure modes

A policy that throws becomes a 500 and the endpoint's real work never runs. That's fail-closed and honest. A 403 would hide a broken policy behind what looks like a working one.

A policy that returns something that is neither a well-formed allow nor a well-formed deny (a missing return on one branch, a copy-pasted { action: "continue" }) denies at the per-action default and logs a warning naming the operation, the thread id and the value. The per-action default is deliberate. Forcing 403 on a read would make a broken policy answer differently from a working one and hand back the enumeration oracle.

B4.run puts no timeout on a policy call, so a policy that hangs on a slow identity provider turns into stuck requests. Put your own timeout around any network call in a policy and fail closed on it.

Load failuresCopy link to section: Load failures

Route middleware that fails to import degrades to "no middleware". An authorization policy must not. A syntax error, a missing dependency or a thrown environment assertion would boot the app with every thread world-writable and no log line. So the loader decides existence with a filesystem check before the import. An import failure can then only mean "the policy is broken".

  • No policy file on disk: no gate.
  • A policy file that fails to import: the boot fails with B4_E3003.
  • A policy file that imports but binds no usable policy: the boot fails with B4_E3003.

The message names which of four cases hit: no default or threadAccess export, the bound value is not an object, fallback is missing or not a function, or a per-action key is present but not a function.

There is no path on which a policy you wrote resolves to "allow everything". See Error codes.

Every boot logs which layer the policy came from, or that there is none. It is the one signal that says a policy vanished:

text
B4.run: thread access policy bound from src/thread-access.ts
B4.run: no thread access policy (all thread endpoints are open)

Build targetsCopy link to section: Build targets

b4 build --target langsmith carries an ownedThreads policy, compiled to LangGraph's metadata-filter handlers alongside the app's src/auth.ts. It fails with B4_E1005 for any other policy, and for a policy with no src/auth.ts. That target materializes per-route graphs and no B4.run HTTP layer, and LangGraph's handlers never see the stored thread, so arbitrary policy code has nowhere to run. A build that silently dropped it would deploy every thread endpoint ungated. See LangSmith.

node, hono and vercel carry the policy. The built manifest has a slot for it: the policy rides in as a static import, and the generated entry point (server.mjs for node, app.mjs for the web targets) records that the build saw one. So the built app never probes the disk for the policy at boot, where a missing file would read as "no policy". For node, the built app is node .b4/build/server.mjs, the entry point of the generated Dockerfile. b4 start doesn't run the build output: it serves the source tree, and reads the policy from source the way b4 dev does. Instead of a disk probe, the built app handles a missing policy like this:

  • On node, the manifest imports the policy from the app's source tree, so a policy file removed after the build fails that import and the server refuses to start. hono and vercel bundle the policy into the build output, so the deployed app doesn't need the source file.
  • A manifest generated before the app had a policy, deployed beside a newer entry point, fails the boot.

Either way the app refuses to start rather than coming up silently ungated. b4 build itself never imports the policy, so building needs none of the secrets the policy reads. The built manifest imports it as it loads, together with the routes and middleware. So a policy that reads a required environment variable at import time needs that variable wherever the manifest loads: the running server, and b4 check run after b4 build. Read secrets inside the hooks instead if b4 check runs somewhere they aren't set.

An app with no policy file builds for every target exactly as before. See Deployment and Edge and Hono.

Adopting a policy on an existing appCopy link to section: Adopting a policy on an existing app

Threads created before the policy have access === undefined. Either handle that branch (admin-only is the usual answer) or backfill.

Backfill is an operator script that constructs the threads store directly. Every in-runtime path to the reserved key is shut on purpose. There is no HTTP endpoint for metadata updates, and the runtime asserts that none of its own metadata patches carry the key.

scripts/backfill-thread-access.ts
import { THREAD_ACCESS_METADATA_KEY } from "@b4run/sdk"
import { createThreadsStore } from "@b4run/sqlite-storage"
 
const store = createThreadsStore({ path: ".b4/threads.sqlite" })
for (const thread of await store.listThreads()) {
  if (thread.metadata[THREAD_ACCESS_METADATA_KEY] !== undefined) continue
  await store.updateMetadata(thread.thread_id, {
    [THREAD_ACCESS_METADATA_KEY]: { ownerId: "operator-assigned-owner" },
  })
}

Like b4 inspect and b4 memory, this assumes a local operator with filesystem or database access. We document it, but we don't defend against it.

Identifiers, never secretsCopy link to section: Identifiers, never secrets

GET /threads/:thread_id returns the raw thread, reserved key included. That's deliberate. Hiding it would break round-tripping and make the stamp undebuggable, and that endpoint is gated by the very policy the stamp feeds. Put identifiers in a stamp. Leave out anything whose disclosure to a caller your read policy admits would matter.

The run endpoints and middlewareCopy link to section: The run endpoints and middleware

The run endpoints (POST /threads/:thread_id/runs/stream, /runs/wait, /resume and POST /agui/{routeId}) plus GET /threads/:thread_id/pending_interrupts, GET /threads/:thread_id/turns and GET /threads/:thread_id/events are on this policy as well as route middleware. The two compose as AND. Middleware answers "may this caller run this route", the policy answers "may this caller touch this thread", and a request needs both. Keep middleware doing its per-caller work.

A run.* operation on a thread that exists arrives under action: "update", because starting a turn mutates the thread. Three of these endpoints (/runs/stream, /runs/wait, POST /agui/{routeId}) also create the thread when the id names no row. B4.run asks about that under action: "create", then again as the update recheck that follows every create (the same two steps POST /threads uses). /resume is the exception. It needs an already-parked thread, creates nothing and is only ever an update.

The stamp your create handler returns is written into the row, just as it is on POST /threads. So a thread born on a run endpoint has an owner from its first turn, and access === undefined keeps its one meaning: created before you adopted a policy.

GET /threads/:thread_id/pending_interrupts, GET /threads/:thread_id/turns and GET /threads/:thread_id/events compose both checks as AND: the route that parked the interrupts must admit the caller and this policy must permit the read. The response doesn't say which one refused. A thread-access deny returns the handler's own 404 thread_not_found, the same bytes a genuine miss returns. A route-identity refusal returns whatever your middleware returns. B4.run logs neither, so log the denial inside your own policy if you need to tell them apart.