docs blog library
github

Surface access

Before exposing an agent to chat apps, webhooks, or peers, configure who can reach it and which tools they may use. Keep the daemon on loopback unless you have configured a private network or authenticated proxy.

The credential and permission controls differ by surface:

SurfaceWho is authenticatedCredentialBounded by
Chat bota platform accountplatform allowlist of chat idsthe agent’s own toolset
Webhookone door, not a persona per-webhook bearer token, or a signing secretdisclosure, allow, and its budget
Daemona client of the daemonone bearer token; grants also need the operator tokenthe agent’s own toolset
Peerone agent identitya per-peer token, or an invitedisclosure, allow, and its budget

The daemon token can start runs. Granting authority (accepting or resuming a goal, starting or resuming a loop, approving or answering a parked run, and any write to an agent or persona) also needs the operator token, which lives only in the OS keyring.

Peers and webhooks do not use it. A credential that can approve a file write is a much larger grant than one that can ask a question.

Chat bots

Set TELEGRAM_ALLOWED_CHAT_IDS to comma-separated chat IDs. Discord uses DISCORD_ALLOWED_USER_IDS, DISCORD_ALLOWED_CHANNEL_IDS, or DISCORD_ALLOWED_GUILD_IDS. A guild grant admits everyone in that guild; prefer user or channel grants when appropriate.

Messages from anyone else are ignored.

Each allowlisted chat gets isolated conversation state. The Telegram and Discord bridges go further when they run as root in the Linux container: each conversation’s agent runs as its own Unix user, under its own Jazz home. JAZZ_BOT_CHAT_ISOLATION=0 turns this off. The iMessage, WhatsApp, and Photon bridges do not do this; every chat shares one JAZZ_HOME and runs as the bridge’s user.

Where it applies, this prevents one person’s agent from reading another’s transcripts, memory, or mail credentials.

See chat surfaces for the per-platform setup.

Webhooks and peers

Both hand a credential to somebody who is not you. Both are bounded on the same two axes: a disclosure tier for what an answer may reveal, and a named allow list for anything that acts or leaves the machine. The security model has the rule.

Tokens live in the keyring, never in config.json, never in a URL, never in a log. Mint them with jazz webhook token <name> and jazz peers set-token <name>, which print the value once.

A webhook fed by a service that signs its deliveries, GitHub among them, should use a signature instead: jazz webhook secret <name> mints the shared secret, the sender signs each body with it, and the secret never travels. A signed webhook also refuses a delivery it has already run, by delivery id and by signature, so a captured request cannot be replayed.

Neither door ever tells a caller more than it must. An unknown webhook name answers the same 401 as a bad credential, and a failed run answers “the run failed” or “could not answer”; the cause goes to the daemon’s log.

A run started through either door is a stranger’s run:

  • None of your context. No standing preferences from memory, no AGENTS.md, and no local file attached because the caller’s text names a path.
  • A budget. budget.maxTokens, budget.maxCostUSD and budget.maxDurationMs on the webhook or peer cap each run, and a run resumed after an approval gets only what it had left.
  • A concurrency cap. maxConcurrentRuns (default 4) per webhook or peer. Past it the door answers 429 with Retry-After, instead of queueing work behind yours.
  • A bounded body. 1 MiB for a webhook, 64 KB for a peer question or A2A request, refused while it streams.

A run that parks on an approval keeps all of this across the park: the same tool list, the same caller, and the budget that is left. A remote run whose record lacks its tool list is refused rather than resumed with the agent’s whole toolset.

The daemon

By default, every route except GET /health needs the daemon bearer token. If secret storage is disabled and no token is supplied, a loopback daemon can run without one; non-loopback startup requires a token. Keep authentication enabled on shared machines. Requests with an Origin header are refused, and JSON routes require content-type: application/json. See Daemon.

The bearer token cannot tell you from an agent. It sits in $JAZZ_DAEMON_TOKEN or, on a host with no OS keyring, in $JAZZ_HOME/secrets.json, and an agent that can read a file and send a request could replay it. So every grant also needs the operator token in X-Jazz-Operator-Token. jazz daemon operator-token mints it into the OS keyring only (never the file fallback, never an environment variable), refuses when a Jazz agent runs it, and a daemon an agent started grants nothing. Without one, the daemon grants nothing over HTTP and you decide from the CLI on the machine. See granting authority over HTTP.

Before you bind a public port

  1. Prefer private access. Bind loopback and reach it over a tailnet or an SSH tunnel.
  2. Terminate TLS at a reverse proxy you already run. Jazz speaks plain HTTP; a token over plain HTTP on a shared network is a token you have published.
  3. Scope who can reach the port, with a firewall rule or a private network. Otherwise the token is the only thing between that interface and an agent with filesystem access.
  4. Run it as its own OS user, with its own home, its own credentials, and no access to anything the job does not need. Jazz’s tool controls bound what the model chooses to do; the operating system bounds what is reachable when that fails.
  5. Rotate on suspicion. jazz webhook token <name>, jazz webhook secret <name>, jazz peers set-token <name> and jazz daemon operator-token overwrite; jazz daemon forget-token and jazz daemon forget-operator-token revoke. A rotated webhook or peer credential takes effect within 30 seconds, without a restart.

machine-readable: /docs/security/surface-access.md · /llms.txt · /llms-full.txt