Agents Honestly
Part XIII · Interface & Experience

Wiring the UI to a Durable Backend

Start, signal, query, reconnect: a React front end over agents that outlive the browser tab.

Exercise

The pieces are specified, and they come from two different parts, which is the reason this chapter exists. Part XI supplied a run with a business identity and a validated acknowledgement in one round trip; Part XIII has supplied a buffered stream that survives a refresh and rendered tool calls. This chapter connects them, and the connecting layer turns out to carry the security of the whole design.

The browser never talks to the cluster

There is no browser client for a workflow engine, and you would not want one. It would mean shipping cluster credentials to a JavaScript bundle. So there is always an API layer between React and the backend, and the only question is what belongs in it.

   BROWSER                  API LAYER                    BACKEND
   ───────                  ─────────                    ───────
   user acts      ──▶  ① authenticate         ──▶   start / signal / update
                       ② authorize the ENTITY
                       ③ derive the workflow id
   render         ◀──  ④ shape for the client  ◀──   query · stream buffer

                       ✗ never: a workflow id
                         supplied by the browser
Four verbs, one API layer, and one thing that must never cross it.

The four verbs

The user…VerbMechanismReturns
Opens a ticket that has no runStartUpdate-with-startA validated acknowledgement, in one trip
Sends a message to a live runSignalSignal, or update if the outcome mattersAccepted / the handler's answer
Loads a page mid-runQueryA query against the live executionCurrent state, once
Comes back after a refreshReconnectThe stream buffer, by Last-Event-IDEverything missed, then live

Four verbs is the whole surface. Anything else your API layer does, such as listing, searching, or history, is a different concern, addressed below.

The workflow ID is the authorization problem

This is the part that is easy to get wrong precisely because the architecture is good.

Part XI made the workflow ID the business entity: ticket-8823. That was the right call. It removed the mapping table, the duplicate-run race, and the progress table. It also made the identifier guessable, and if the API accepts a workflow ID from the browser and proxies it, any authenticated user can address any ticket in the company.

That is textbook broken object-level authorization, arriving through a design decision that was correct for other reasons.

The browser sends the business identity. The API checks entitlement, then derives the workflow ID.

ts/src/api/tickets.ts
app.post('/tickets/:id/reply', async (req, res) => {
  const session = await requireSession(req);              // ① who is this
  const ticket = await tickets.get(req.params.id);
  if (!canAccess(session, ticket)) return res.sendStatus(404);   // ② may they

  await client.workflow                                    // ③ derive, never accept
    .getHandle(`ticket-${ticket.id}`)
    .signal(inboundEvent, { type: 'customer_reply', body: req.body.text });

  res.sendStatus(202);
});

Note 404 rather than 403 on the authorization failure. Telling an attacker that ticket 8824 exists is most of the work of enumerating it.

This is the same rule as the requester rule from Part VIII, one layer out: the caller proposes the subject, your code supplies the authority. The pattern has now appeared at the tool boundary, the policy layer, and the HTTP layer, which is a reasonable sign that it is the actual invariant of the whole system.

Do not poll queries

A query executes against the live workflow. It is cheap once and it is not free, and polling one per second per open tab produces load that scales with browser tabs rather than with work. That is the wrong quantity to scale with, and the load arrives during exactly the incident where everyone has the dashboard open.

The division that works:

NeedUse
Live updates on one runThe stream, which is already buffered
Current state on a cold page loadA query, exactly once
"My open tickets", search, historyA read model. The cluster is not a database

That last row deserves emphasis, because it is a limit people hit late. You cannot list workflows the way you list rows. A view of every open case, filtered and sorted and paginated, is a query against a database that your workflows write to as they progress: a projection, updated from the run rather than interrogated by the page.

The honest cost is that a projection is eventually consistent and can briefly disagree with the live execution. The resolution is a rule rather than a synchronisation effort: the read model backs the list; the query backs the detail view. A stale row in a list is a cosmetic problem. A stale detail view is someone approving a refund against a state that has moved.

The cold load has an ordering problem

A page opens and the run is at step seven. The obvious sequence is: query for state, render, attach the stream. That sequence drops events. Anything emitted between the query returning and the stream attaching is gone, and nothing reports it.

The fix is the same stream-first ordering this book has now needed three times:

   ① open the stream          (it begins buffering immediately)
   ② query for current state  (a snapshot)
   ③ render the snapshot
   ④ apply buffered events, deduped by id
   ⑤ continue live

Open first, snapshot second, reconcile by ID. The stream is already buffered, so nothing is lost while the snapshot is in flight.

Four things the API layer must not do

Do not hold the request open for the run. Eight minutes against a sixty-second proxy timeout. The whole of Part XIII exists because of this.

Do not start work on GET. A page load, a prefetch, or a link preview will then start an agent. Starting is a POST, and it is idempotent because the workflow ID deduplicates it.

Do not proxy identifiers. Covered above, and it is the one that becomes a security finding rather than a bug.

Do not put credentials in the stream. The stream is buffered, possibly in Redis, possibly for an hour after the run ends, and is readable by anything that can reach the buffer with the stream ID.

Multi-tab and multi-device come free

If the stream is a projection with a buffer, then two tabs, a phone, and a colleague watching the same ticket are all just additional readers of one buffer. Nothing about the run changes, nothing coordinates, nothing races.

The failure this avoids is the one where each surface starts its own run, and it is the same failure a refresh causes when the stream and the run are the same object. If opening a second tab can double your token spend, the architecture is wrong in a way no amount of front-end work will fix.

Atlas, concretely

EndpointVerbNotes
POST /tickets/:id/resolveStartUpdate-with-start; returns a validated ack
POST /tickets/:id/replySignalCustomer reply into the live run
POST /tickets/:id/approveUpdateReturns whether the decision was accepted
GET /tickets/:id/stateQueryCold load only, never polled
GET /tickets/:id/eventsStreamSSE, Last-Event-ID, buffered per run
GET /ticketsRead modelPostgres projection, written by the workflow

Six endpoints, and the only one that touches a database directly is the list. Everything else addresses a running execution through an identity the API derived after checking the session. So the browser has no knowledge that a workflow engine exists, and could be pointed at a different one without changing a line of React.

Takeaways

  • There is no browser client for a workflow engine and there should not be. An API layer always exists; the question is what belongs in it.
  • Four verbs cover the whole surface: start, signal, query, reconnect.
  • Making the workflow ID the business entity was correct and made it guessable. Accepting one from the browser is broken object-level authorization.
  • The browser sends the business identity; the API checks entitlement and derives the workflow ID. Return 404 rather than 403, or you have confirmed the record exists.
  • This is the requester rule at the HTTP layer. Having appeared at the tool boundary, the policy layer, and here, it is the system's actual invariant.
  • Never poll a query. Polling load scales with open browser tabs rather than with work, and peaks during the incident when everyone is watching.
  • Stream for live updates, query once on cold load, and project to a read model for lists. The cluster is not a database and you cannot list workflows like rows.
  • The read model backs the list; the query backs the detail view. A stale list row is cosmetic; a stale detail view is an approval against a state that moved.
  • Cold load is stream-first: open the stream, take a snapshot, render, reconcile buffered events by ID. Querying first drops whatever arrives before the stream attaches.
  • The API layer must not hold the request open, must not start work on GET, must not proxy identifiers, and must not put credentials in a buffered stream.
  • Multi-tab and multi-device are free when the stream is a buffered projection. If a second tab doubles your token spend, the architecture is wrong.

It streams, it survives a refresh, and none of that decides whether a person is willing to rely on it. Next: Interface Patterns for Trust, the vocabulary that makes an agent usable by somebody who starts out sceptical.

On this page