Intents and guards#

An intent is a named, schema-typed action on a component. It is the unit of interactivity: a human click and an agent tool call execute the exact same pipeline — guard check, input validation, run(), audit.

checkout: intent({
  description: 'Pay for the cart. Has monetary side effects.',
  input: schema({ note: str().optional() }),
  guard: 'confirm',
  ready: ({ sources }) => !sources.catalog.pending,
  run: async ({ state, derived, emit, input }) => {
    const order = await pay({ total: derived.total });
    emit('cart.checkedOut', { orderId: order.orderId, total: order.charged });
  },
}),

Guards — human-in-the-loop as a keyword#

Every intent (and every api()) declares who may invoke it:

Guard Human click Agent call
auto (default) runs runs unattended
confirm runs returns a proposal; a human approves or rejects
forbidden runs invisible in the manifest; invocation rejected

Guards can be dynamic, and they see both the request context and who is asking: guard: ({ ctx, origin }) => (ctx.role === 'admin' || origin === 'human' ? 'auto' : 'confirm'). The most common origin-aware guard — instant for people, approval for agents — is ({ origin }) => (origin === 'agent' ? 'confirm' : 'auto'); the manifest advertises it as the agent will experience it (confirm). run() receives the same origin, so an intent can also branch its behavior (see Human vs. agent behavior).

Proposals#

When an agent hits a confirm intent, execution suspends and a proposal is emitted (a janux:proposal DOM event on the client; a stored proposal on the server for api tools). Approving executes the original closure exactly once; approvals cannot be replayed.

const proposal = await window.janux.call('cart.checkout'); // { status: 'proposal', id, ... }
await window.janux.approve(proposal.id);                   // runs it

An api() proposal created from the page — window.janux.call('api.…') — is mirrored client-side: the same janux:proposal event fires, and approve(id) / reject(id) settle it through /_janux/approve / /_janux/reject. A proposal a remote agent parks over HTTP stays server-side only: there is no push channel to open pages yet.

Tip: give your copilot component human-only intents (guard: 'forbidden') for approve/reject, so an agent can never approve its own proposal.

The proposal token: threat model and guarantees#

A server-side proposal is a capability: whoever presents its token to /_janux/approve executes the parked call. If you are evaluating Janux for a setting where that approval is the control — payments, PII disclosure, anything regulated — this is the exact contract, and each row is pinned by a test that fails without the mechanism behind it.

The token is <id>.<signature>: a random id (the lookup key, and the only part that appears in audit entries and observability spans) plus an HMAC-SHA256 over the id, the proposing session and the payload's hash, under a key generated per server instance. Approval re-derives the signature from the approving request's session and the currently stored payload, compares it in constant time, and consumes the proposal in the same synchronous pass.

Attacker What they hold Why the approval fails
Replays a settled token The full token, already approved or rejected once Consumption is atomic with verification: the second attempt finds nothing (404)
Resends an old token A token lifted from a backup, log or screenshot hours later Proposals expire — ten minutes by default, proposalTtlMs to tune (410)
Approves from another session The full token, in a different browser The signature covers the proposing session, derived from the request's own cookies (403) — and the refusal does not consume the proposal, so the owner still decides
Swaps the payload A valid token, plus a way to alter what it executes The signature covers the payload's hash, re-checked against the stored input at approval time (403)
Redeems on another instance A token minted by a different server process The signing key never leaves its instance, and proposals live in that instance's memory
Reads your traces Audit entries and spans They carry only the bare id, which no longer settles anything on its own

Two boundaries to know. "Session" is the hash of the request's cookie header: same browser, same session — no framework cookie is minted, so responses stay cacheable and cookieless callers (tests, remote agents) bind to the empty session. And a proposal parked by a remote agent binds to that remote caller's (usually empty) session, which today can only be settled server-side — consistent with there being no push channel to open pages.

The TTL is one server option:

proposalTtlMs: 5 * 60_000, // approvals expire after five minutes; default is ten

The default survives a coffee break on purpose: hardening the token must not make the human flow worse. The four attacker rows are the corpus in packages/conformance/security/proposal-tokens.cases.ts; the vault itself — including the payload-mutation case no HTTP client can express — is covered in packages/janux-server/src/proposals.test.ts.

Readiness#

ready declares preconditions as data. A not-ready intent is announced as ready: false in the manifest — agents know they should wait instead of failing at runtime.

Binding intents to the view#

  • Click: <button onClick={intents.addItem.with({ productId: 'p1' })}>Add</button>.with() renders the element's data-input for you.
  • Form: <form onSubmit={intents.send}><input name="text" /></form> — form fields become the input object.
  • Any other event the same way: onDoubleClick={intents.open}, onWheel={intents.zoom}, …

All compile to data-jxa / data-jxform / data-jxe-* markers handled by delegated listeners; no per-element handlers exist in the DOM.

Errors and audit#

Invalid input throws with precise paths (items[0].qty: below min 1) before run() executes. Every invocation — human or agent, allowed or rejected — produces an audit entry: tool, origin, guard, input, ok/error (an AuditEntry; proposed: true marks a parked confirm call that has not run yet).

On the client every entry reaches the page twice, mirroring janux:proposal: boot({ onAudit }) for the app's own code, and a janux:audit DOM event for islands that only see the document. An audit-trail island subscribes instead of re-recording actions inside each run():

import { boot } from 'janux/client';
import type { AuditEntry } from 'janux';

boot({ onAudit: (entry: AuditEntry) => console.log(entry.tool, entry.origin, entry.ok) });
// …or, from any island / plain script:
document.addEventListener('janux:audit', (event) => {
  const entry = (event as CustomEvent<AuditEntry>).detail;
});

bag.origin is always defined — 'human' outside intent runs (view, effects, lifecycle), the caller's origin during one — so run() can branch on it without a fallback.