Documents saga a2ui 0.11.0-rc.1 (prerelease). This is the SDK guideAfter running the setup guide, use the notes application to change a control and follow an Archive press from the row through prerequisites, server authorization, feedback and cleanup. The same action definition serves every row, while each row keeps its own pending state and current note identity. Each quoted block is copied from the file named above it;docs/actions.mdat source revisionfb4126c. It is published as a snapshot: later source changes do not alter it. Licensed under Apache-2.0.
scripts/guide-actions-document.test.ts checks that copy. scripts/guide-actions.test.ts exercises the traces against the notes application installed from packed candidate tarballs.
All seven SDK packages, including Lit and both stacks, are published on npm. This runnable tutorial uses a source checkout and a candidate: matched locally packed tarballs that exercise the checkout together (install and update). The action APIs described here also ship in 0.7.7-rc.1. The timeout values below are SDK defaults; choose application budgets for the work your backend performs.
The runnable tutorial is the notes application (examples/pack1-consumer) on the Lit renderer and the Node-style MCP delivery the setup guide qualifies. Other renderers and servers are explained only as far as their own evidence goes (evidence levels).
Run it first
From a checkout:repository
apps/lit-host/e2e/pack1-consumers.spec.ts; the development reference lists the build and browser commands.
Who owns what
Five parties touch one press, and each owns one kind of decision. Keeping them apart is the point of the design: you can change a component’s layout, an action, or the server’s policy without the others changing.
The path of one Archive press, in order:
Types check your code; the wire is checked at run time
Three kinds of value cross a boundary in this application, and TypeScript guarantees none of them at run time:- A press carries an event.
event.read('id')returnsunknown, because the data scope is whatever the layout’s data model holds. The application validates it ininput:
examples/pack1-consumer/src/notes-actions.ts
input), before anything is held or sent.
- A request carries tool arguments. The server declares a strict schema, so a caller cannot add an account, send a number for an ID, or send an empty one:
examples/pack1-consumer/src/notes-contract.ts
{ noteId: 'n-1', accountId: 'acct-bob' }, { noteId: 42 } and { noteId: '' } to archive_note; every one is rejected and the backend is never asked.
- A reply carries
structuredContent. The client’sCallToolResulttype says it may be anything. The handler parses it withArchiveOutputSchemabefore using it, so a server of another build cannot put a wrong shape into the view.
Action.server<ActionButtonActivation, CallToolResult> tell the compiler what you expect. They check nothing that arrives. Put a runtime check wherever a value comes from outside your code, and treat the type as documentation of that check.
Change a component, and what changes with it
The layout is a list of component definitions with IDs. In the notes application the Archive control is two of them:examples/pack1-consumer/src/notes-surface.ts
{ componentId: 'note-row', path: '/notes' } repeats it per note, so the renderer creates one Archive button per note, all from the one row-archive definition. The vocabulary is in the setup guide.
Application code attaches behavior to those instances when the surface is presented:
examples/pack1-consumer/src/notes-view.ts
- Change the label (
text: 'Archive'to'Archive note'). Only the layout changes. The binding finds the control by its ID, so it is untouched. - Change what Archive does. Edit the action in
notes-actions.ts. The layout and the renderer are untouched. Add a prerequisite is the example below. - Rename the ID in the layout without renaming it in
watchById. Setup finds no instance, so nothing is bound. The control still renders, a press does nothing, no error is raised and no request is sent. The traced test opens such an instance and getsunboundback fromactivate. Discovery runs at setup, once per presented surface, so this fails quietly: after renaming an ID, press the control.
A local action first
Mark read is a local action with no prerequisite. It changes application state without a server call. Local actions can have prerequisites too; this example is the smallest complete action:examples/pack1-consumer/src/notes-actions.ts
input copies what the handler needs out of the event, once, synchronously, while the event is being delivered. After that the handler uses params, never the event: event.read throws once the event turn is over, so a continuation cannot read whichever note the row shows later. The definition holds no state, so the same one is bound to every row.
The same mechanisms in protected playback
Play is the protected, long-running case of the same shape, not a different mechanism. Inapps/lit-host/src/playback/playback-view.ts it is an Action.server with an input that selects the item, an atActivation that takes the player’s lease, two before prerequisites (the host can forward server tools; a recipient key is created), a handler that makes one call, and the four outcome hooks. Pause and Resume are local actions bound the same way. All three are bound with a subject reader on the current selection, so a new selection cancels an undecided Play.
What stays with playback is playback’s own: the gesture the browser needs, the player’s lease token and where the control is placed. The authorized playback guide and the playback source own those decisions; this guide only shows that the mechanisms are the ones above. The notes application is the runnable example because it is small and needs no audio.
Trace one press, allowed and denied
Here is the whole Archive definition, in the order the runtime runs it. Prerequisites run strictly in the order written; the handler only starts after every one proceeded.examples/pack1-consumer/src/notes-actions.ts
inputreads the note’s ID and the row’s data path from the pressed instance. This is instance-qualified input: two rows share the component IDrow-archive, so the ID cannot say which note was pressed. The data scope can.atActivationruns in the same turn and takes the application’s hold on the row. It runs before anything is awaited, because some things (a browser audio unlock is the real example) must happen inside the user’s gesture.before(requireSession)asks the server whether the session is still valid, with a request of its own (check_session).runsends the protectedarchive_noterequest throughcall, which uses the transport the application gave the action. The request carries the invocation’s signal and the time left on the work deadline.- The server decides again. Here is its side of the same call:
examples/pack1-consumer/server/notes-server.ts
ctx.http.authInfo, which only the application’s listener sets, after verifying the credential. No tool argument can name an account. The decision to archive is made here, for this request, by the application’s backend. The reply has three shapes: a result, a refusal the server knows changed nothing (refusal, marked with NO_EFFECT_META), and any other failure (failure), which says nothing about whether the write happened.
What each case returns
These are the casesscripts/guide-actions.test.ts runs, with what it observed. “Backend calls” counts calls that reached the application’s backend; the session check is counted apart from the protected archive.
Two of those rows are the rule the whole design rests on. A prerequisite authorizes nothing: the session check says yes, the permission is withdrawn, and the server still refuses, because it decides for every protected request. And a request with no verified identity is refused by the server before the backend is asked.
The notes application answers a missing credential at the tool level, with a sentence. An application’s own listener answers it at the HTTP level, which is where the starter does it, and its qualification traces each case:
Each answer carries the challenge a client needs to recover, and none names an account, a credential or a backend message. The starter’s identity handoff is where the listener turns the verified result into the per-request context the tool reads.
Prerequisites: session validity, confirmation and an audit entry
A prerequisite is reusable asynchronous work that must finish before the handler and may stop it. Itproceeds, optionally providing values to what follows, or it refuses with a reason. A refusal ends the invocation as a failure at the prerequisites stage: the handler never starts, so the protected request is never sent. A prerequisite that makes a request of its own (the session check does) is counted as that request, not as the protected one.
The notes application’s session check is one prerequisite, reusable by any action:
examples/pack1-consumer/src/notes-actions.ts
scripts/guide-actions/guarded-archive.ts, qualified by the test; they are not in the notes application and not SDK exports):
scripts/guide-actions/guarded-archive.ts
scripts/guide-actions/guarded-archive.ts
scripts/guide-actions/guarded-archive.ts
provided.auditId reaches the handler and every later hook as a typed value, and the compiler rejects an ordering in which a prerequisite needs a value nothing provided yet. parseArchived is the application’s own validation of the acknowledgement (the test passes the notes example’s ArchiveOutputSchema.parse): a reply that is not an error is not yet proof that the archive happened, and a reply that fails validation fails the handler, so the audit entry settles as unknown, as for any other unreadable outcome, with no retry. The traced test runs this chain:
A prerequisite is not the control’s appearance. A spinner or a disabled button is the presentation of binding state:
binding.state.phase is idle, running, settling or disposed, and binding.subscribe calls a listener after each change. What before means is only “must finish first, and may refuse”. A disabled button does not stop a press that reaches the callable, and a prerequisite does not draw anything.
Outcomes, hooks and cleanup
Every invocation ends in exactly one outcome, decided once and never edited:succeeded, failed at a stage (input, prerequisites, running), or canceled at a stage with a cause (replaced, disposed, subject-changed). Named hooks let the application say what each outcome establishes:
examples/pack1-consumer/src/notes-actions.ts
- Success. The server confirmed.
onSuccesshands the hold to its owner (transfer) and writesArchived at 1001. - Refusal. Either a prerequisite refused (the handler never started, so the hook can say nothing was sent) or the server declared a refusal with no effect (the hook can say nothing changed). Both are known results, and both give the hold back.
- Canceled or obsolete. The binding was disposed, or the row now shows another note (
subject-changed). The press is no longer wanted.
current() immediately before its write. current() is true only while the runtime is still waiting for this callback, the binding is neither disposed nor replaced, the adapter reports the instance live, and the subject still equals the one captured at activation. It is read fresh on each call. In onSuccess above, the hold is handed over unconditionally (the server did confirm), but the row’s status line is written only if (current()), because after a recycle that line belongs to another note. A signal that has not aborted does not show the row is still the right one; current() does.
The traced recycled-row case shows both halves. A press on n-1 is held open at the server; the row is then changed to show n-2 (its component ID is unchanged); the press ends canceled with cause subject-changed. Observed: n-1 was archived, because cancellation did not stop the server; nothing was written on the row that now shows n-2; and the hold was given back, not handed over.
Four clocks, kept apart
The rules that connect them:
- No clock is a universal invocation timer. The work deadline does not cover the hooks, and the hooks do not renew it. A new stage never resets its parent’s bound, because every deadline is absolute from where its scope began.
- An invalid explicit limit fails (
TypeError) where it is supplied, even if another layer would have shadowed it. It is never replaced with a default. The traced test passes0,NaNand-1. - There is no automatic retry, and none is safe to assume. An uncertain write (a timeout, a lost connection, an unreadable reply) is reported as unknown.
- Cancellation and timeout do not establish rollback or server cancellation. Aborting the signal ends the client’s wait; the host may or may not forward cancellation, and server work must cooperate with its own signal. The traced deadline case sets a 150 ms work deadline and holds the server’s archive open: the person sees
Archive status unknown, the hold is given back, and then, when the server’s backend is let go,n-1is archived anyway. The outcome was unknown, and it was applied.
failure, never refusal, because ending a wait undoes nothing.
Two rows, one definition
One definition bound to two instances keeps each binding’s own state:examples/pack1-consumer/src/notes-view.ts
running while the second is idle; both holds are pending at once (n-1, n-2); when the server is released, both archives apply and both holds are handed to their owner. A pending press on one row never disables or cancels the other.
The subject reader is what makes a recycled row safe. A list renderer may keep a row’s component and show another item in it. The component ID is not the item’s identity, so the binding takes a subject, here handle.read('id'), reads it at activation, and compares a fresh read with Object.is at its own decision points and on every current(). A reader that throws at activation ends the press as invalid input; one that throws later counts as a change. Without a subject, a press targets the instance alone, which is right for a fixed control and wrong for a row.
Disposal, ownership and hand-off
Disposal.binding.dispose() is idempotent. It cancels an undecided press with cause disposed, detaches that binding’s callable so later events do nothing, and ends its subscription after a final disposed state. It leaves other bindings of the same definition alone. The surface’s action setup owns discovery and the bindings it creates. The stack cleans up that setup when the view ends. The application’s onTeardown callback (retire() in the starter) releases application-owned resources; do not rely on it running after every renderer’s binding cleanup. Host teardown, dispose() and page exit all use that path. Ending a binding stops the control reacting. It does not prove that work a server action started has stopped.
Ownership. A resource is anything a press takes that someone must give back. The notes application’s hold on a row is an application-owned resource, and it shows the whole contract:
examples/pack1-consumer/src/row-holds.ts
atActivation took the hold with scope.own(token, release). From then the scope owns it and releases it when the invocation ends, one release step per token inside the 5 s hook budget. On success the application takes it over with activation.transfer(...), which runs synchronously, and from then the scope never releases it. The three outcomes the traced test checks: success leaves confirmed: ['n-1']; refusal and cancellation leave nothing held.
Why stale cleanup must not alter a newer acquisition. A press that ends late runs its cleanup late. By then the application may have a newer acquisition for the same row. So the release is given a token that names exactly one acquisition, end(token) only affects an entry that is still pending and is a no-op for one already given back or confirmed, and the runtime never calls anything that means “release everything the owner holds”. Stopping the whole owner is the owner’s own teardown and never an invocation’s shortcut. If your owner keys a hold by the row instead of by the acquisition, an old press’s cleanup will release the new press’s hold; key it by token.
What each renderer and server has shown
Claim only what the evidence supports. The runnable tutorial stays on the qualified pair.
On React the binding is the same definition and the same
bindAction; only how the setup runs differs. The notes React consumer’s setup is the application’s own function, given to createReactActions from @saga-a2ui/react-mcp-stack. The renderer package owns that implementation and the stack re-exports it. The React stack (@saga-a2ui/react-mcp-stack) renders the surface inside the scope that call returns, which owns the provider, runs the setup and ends it all when the view ends: unmounting that root disposes every instance and binding beneath it. A setup that throws is reported to onSetupError, never left to blank the view.
examples/pack1-consumer/src/notes-react-view.tsx
@saga-a2ui/react-mcp-stack. A deliberately lower-level integration uses @saga-a2ui/react/a2ui for surfaces and catalogs, never @a2ui/react directly (React consumer dependencies). Any later React pair keeps its own renderer qualification.
The five server targets—node, express, fastify, hono and hapi—are qualified with lit-mcp, and saga init offers all five. Everything in the trace above is the same for each qualified server, because the identity handoff and the protected operation are the same two snippets in each recipe (which pairs have a recipe).
Hapi
The Hapi identity handoff starts in the authentication strategy intemplates/lit-hapi/server/listener.ts.
The bridge admits one request lifetime before authentication. The strategy accesses it through
mcpRequestLifetime(request); authenticate uses it to bound verification and copies and freezes
the verified account before the body wait. Hapi stores that
context in request.auth.credentials.authInfo; the maintained
bridge.ts passes { authInfo, lifetime, request } to createServer and the auth to the Node
transport. The bridge owns bounded parsing, deadlines, disconnect handling and shutdown cleanup. The protected operation’s identityOf hook reads that auth,
and its handler applies the application’s policy. Tool arguments cannot name the account.
An explicitly public route reads no credential and receives no identity.
The generated Hapi recipe uses those template
files. scripts/starter-hapi-lifecycle.test.ts qualifies direct registration and ordinary-route isolation;
scripts/starter-hapi-identity.test.ts checks the real Hapi/MCP factory and callback;
scripts/starter-hapi.test.ts runs the packed-starter allowed, denied, missing-auth, public and
overlapping-principal cases, including reused credential records. The built Lit view is exercised
by the Hapi cases in apps/lit-host/e2e/starter.spec.ts.
What this guide does not show
- It traces a loopback run against synthetic data. It shows no real identity provider, OAuth or DCR flow, and no run in ChatGPT, Claude or Alexa+. Named-host receipts are in the host compatibility observations.
- Cancellation forwarding, rollback and gesture behavior in an actual host stay unobserved.
- The prerequisites above are guide qualification code, not part of the notes application or an SDK export. The SDK has no promise-combinator, registry or tutorial dispatcher, and this guide adds none.
- Package publication, deployment and visibility are separate decisions this guide does not make.
Where to go next
- The setup, install and attach recipes: Getting started and Attach SAGA to an existing backend.
- The design behind the vocabulary: action binding and request and view lifetimes. The Blueprints explain the approved contract, implemented scope and remaining proposals; the shipped exports and examples establish the API you can use.
- Updating or rolling back the candidate: Candidates: contents, update, rollback.