Using agents-cli as a programmatic observability layer for agent fleets.
agents feed and agents mailboxes share one fleet-comms visual language (masthead + glyphs from comms-render) so the two operator surfaces read as one product.
Separate from the fleet-state sources below (which answer "what's running now"),
the audit event log answers "who did what, and from where". Every
agents <module> <command> invocation is recorded — team create/disband, agent
run, secrets access, version installs — as a structured JSONL line at
~/.agents/events.jsonl (directory 0700, file 0600). At 10 MB the active
file rotates losslessly to events.1.jsonl.gz; older archives shift to
events.2.jsonl.gz, events.3.jsonl.gz, and so on.
The recording is a single choke point — a commander preAction/postAction
hook on the root program (src/index.ts) emits command.start
/ command.end for every subcommand, so coverage is automatic and no per-command
wiring can drift out of date. Richer typed events (secrets.get, version.install,
teams.create, teams.disband, …) layer on top where the extra payload earns it —
e.g. team lifecycle events are emitted at the registry source with the team name,
so they fire for every path (teams create and the auto-create in teams add).
Every record carries attribution computed once per process
(src/lib/events.ts):
osUser— the OS account that ran it.transport—local, orsshwhen$SSH_CONNECTIONis present.sshClientIp— the remote client IP when over SSH.caller—claude-code, a Factory terminal agent (claude,codex,gemini,cursor, …),terminal, orscript.session— the short Factory session id when one is present.
So "was this agent started on the host by a remote user?" is answerable for any
event, not just runs. The write is a synchronous single-line append (durable
before the action proceeds); AGENTS_DISABLE_EVENT_LOG=1 turns it off.
agents events # recent activity across everything
agents events --module teams # team lifecycle (create / add / disband)
agents events --module secrets # every secret accessed or revealed
agents events --command "teams create" # a command path — prefix match
agents events --event teams.disband # a semantic event: a team torn down
agents events --event secrets.get --since 7d --json
agents events -f # live tail of today's log--module filters the top-level group; --command matches a command path by
prefix (teams catches teams create); --event filters a typed event
(repeatable); --since takes 2h/7d/4w or an ISO date. --json emits the
raw records for external consumers.
Secret-bundle reads are audited at the read, not just at the command.
agents events --module secrets (or --event secrets.get) surfaces every path
that resolves a secret VALUE out of a bundle — run --secrets, secrets exec/export, the MCP get_secret tool, secrets view --reveal, the raw
secrets get <item>, secrets push (which reads the whole bundle to upload
it), and remote bundle@host resolves. (Value reads in adjacent subsystems that
don't go through the bundle resolver — e.g. wallet, profile auth tokens — are
not part of this secrets.* stream.) Each record carries a source telling you
HOW it was read — keychain (real Touch-ID read), agent (served from the unlocked
broker), reveal, raw-item, sync-push, or remote (with the host) — plus
the bundle, caller, keyCount, and OS-user/host/transport. The resolved
value is never written to the log — only names and counts. Note the event
log has a 7-day retention (older daily files are pruned), so export what you need
for long-term records.
While agents events is a convenience alias, the full audit surface lives under
agents logs:
agents logs audit # recent activity (last 100)
agents logs audit --level audit # security-relevant only
agents logs audit --module teams # team lifecycle events
agents logs audit --command "secrets get" # by command path prefix
agents logs audit --caller claude-code # only commands invoked by Claude Code
agents logs audit --event mcp.add # by typed event (repeatable)
agents logs audit --since 7d --json # machine-readable, last 7 days
agents logs audit --follow # live tail of today's logEvents are classified by level:
| Level | Meaning | Examples |
|---|---|---|
audit |
Security-relevant | secrets.get, secrets.reveal, teams.create, teams.disband, cloud.dispatch |
warn |
Warnings | warn events |
info |
Informational | info, command.start, command.end, mcp.add |
debug |
Diagnostic | debug events |
Every record includes the environment-derived caller identity, so the audit
trail answers which agent or human surface invoked the command rather than which
TypeScript source file happened to emit it. Filter with --caller.
agents logs stats # breakdown by level, event, module, user
agents logs stats --since 30d # last 30 days
agents logs stats --json # machine-readableFiles exceeding 10 MB rotate to numbered gzip archives without overwriting an earlier archive. Archives older than 7 days can be pruned explicitly with:
agents logs rotate # prune archives older than 7 days
agents logs rotate --days 7 # prune files older than 7 daysThe query() API reads the active JSONL and every numbered gzip archive
transparently.
External tools (dashboards, voice assistants, CI runners, monitoring) can read
fleet state via three canonical --json sources. No direct DB access, no re-parsing
of agent-specific formats, no auth to manage.
agents hq floor --json is the management bridge for the Agents HQ office view.
It joins the same live sources used elsewhere (sessions --active, teams list,
team status, and feed blocks) into one floor-shaped payload:
rooms— team rooms first, then machine rooms for standalone agents.agents— live occupants with mood (working,waiting,blocked,celebrating,idle), machine/team placement, preview text, and action cards.ambientEvents— animation triggers for needs-input, blocked, PR, active-team, and idle-room scenes.actions— runnableagentsargv arrays for floor-level actions such as creating a team room.
Per-agent and per-room actions are command-backed instead of inventing a second write API. Agents HQ can render them as buttons and execute the returned argv:
agents hq floor --json | jq '.agents[0].actions'Typical action commands are agents message <mailbox> ... --surface hq,
agents feed --kill <mailbox>, agents teams stop <team> <teammate>,
agents sessions <id> --markdown, and agents teams add <team> ....
Agent Fleet
│
┌───────────────────┼───────────────────┐
│ │ │
Local sessions Cloud dispatches Team DAGs
(on this laptop) (remote VMs) (coordinated runs)
│ │ │
▼ ▼ ▼
agents sessions agents cloud list agents teams list
--json --json --json
│ │ │
└───────────────────┴───────────────────┘
│
▼
External consumer joins
by sessionId / cwd / task_name
Each source answers a different question:
| Source | Question | Coverage | Misses |
|---|---|---|---|
agents sessions --json |
What local CLI and team-spawned agents have run recently? | Claude, Codex, Gemini, OpenCode, OpenClaw on this laptop | Pure-cloud runs with no local file |
agents cloud list --json |
What am I running on remote VMs right now? | Rush Cloud, Codex Cloud, Factory | Local sessions |
agents teams list --json |
What multi-agent DAGs are active? | All team-coordinated runs | Standalone agents |
agents sessions --active --json includes attachments for prompt-side
screenshots and files when the source transcript carries a local path. Each entry
contains path, name, mediaType, and sizeBytes so consumers such as Factory
can render thumbnails and open the original attachment without re-reading the raw
agent transcript.
Some sessions appear in multiple sources:
- Local CLI:
sessionsonly agents teams add:sessions(withisTeamOrigin: true) +teamsagents cloud run:cloudonlyagents teams add --cloud:teams+cloud
sessions.id ↔ cloud.id (when a team spawns a cloud teammate)
sessions.cwd ↔ teams.workspace_dir (when a team runs local)
teams.task_name ↔ sessions.teamOrigin.handle
Use these to build a unified view. Two common patterns:
CWD=$(pwd)
agents sessions --json --all --since 2h | \
jq "[.[] | select(.cwd == \"$CWD\")]"# Three calls in parallel, merged by consumer
agents sessions --json --all --since 2h &
agents cloud list --json &
agents teams list --json &
waitsetInterval(async () => {
const [local, cloud, teams] = await Promise.all([
exec('agents sessions --json --all --since 10m --limit 50'),
exec('agents cloud list --json'),
exec('agents teams list --json'),
]);
updateDashboard({ local, cloud, teams });
}, 5_000);// Called each time the user asks a question
async function briefing() {
return Promise.all([
exec('agents sessions --json --all --since 2h --limit 30'),
exec('agents cloud list --json'),
exec('agents teams list --json'),
]);
}# Alert if any cloud task has been stuck > 30 minutes
agents cloud list --json | jq '.[] | select(.status == "running")' | \
your-alerting-tool# Get the full normalized event array for one session
agents sessions <id> --json --last 50 --include tools,assistant
# Or as markdown for human reading
agents sessions <id> --markdownThe three sources above tell you what agents are running; agents mailboxes
shows what they are saying to each other. Every agents message /
agents teams message / feed answer rides the mailbox spool
(~/.agents/.history/mailbox/<id>/{inbox,processing,consumed}) — one box per
logical agent. agents mailboxes (alias agents mailbox) is the read-only
window onto it, including already-consumed (delivered) mail, so agent-to-agent
chatter is visible after the fact.
agents mailboxes # masthead + 24h sparkline + boxes + recent cross-box log
agents mailboxes <id> # one box in full, across all three buckets
agents mailboxes --watch # live tail of cross-box traffic until Ctrl-C
agents mailboxes --between a b # one relationship as a thread, either direction
agents mailboxes --graph # who-talks-to-whom adjacency, busiest firstThe overview opens with a fleet comms masthead (N live · M boxes, total
messages, messages still awaiting delivery, last activity) and a 24-hour
hourly-volume sparkline, then one row per box (live dot, pending/total counts,
last activity, resolved live-session label), then the recency-ordered message
log.
agents feed lists decisions agents need from the operator. The default order is
cost-of-delay rank, not chronology: idle minutes × downstream blast radius ×
hourly burn × ask irreducibility. Suppressed stalls and FYIs get zero
irreducibility, so a fresh cheap ask does not outrank an old critical-path block.
The same poll also synthesizes control cards for sessions that are burning
abnormally without asking (runaway) or asking repeatedly (needy). Control
cards are one row per session, with local controls:
agents feed --pause <id> # SIGSTOP a local process; cloud tasks are cancelled
agents feed --kill <id> # SIGTERM a local process; cloud tasks are cancelledagents mailboxes --watch # stream new cross-box messages as they land
agents mailboxes --watch --since 1h # backfill the last hour, then keep tailing
agents mailboxes --watch --from claude # only one sender
agents mailboxes --watch --json # NDJSON, one message per lineEach line is HH:MM:SS <from> ─→ <toLabel> <text>. When a message is
addressed to the box of the agent running the watch (spawn wiring sets
AGENTS_MAILBOX_DIR), the target renders as ▲ you in amber — an orchestrator
agent sees its replies light up in the stream. Ctrl-C aborts the poller
cleanly. Without --since the watcher does not replay history; it baselines
the spool and streams only what arrives after that.
All three apply to the overview recency log, the --watch stream, and
--graph:
| Flag | Matches |
|---|---|
--from <agent> |
Sender label contains <agent> (case-insensitive) |
--to <agent> |
Recipient box id or resolved label contains <agent> |
--since <dur> |
30s / 5m / 2h / 7d / 4w or an ISO date |
Reads one relationship: every message between the two boxes in either
direction, chronological, under a a ⇄ b N messages · span header. Boxes
resolve by full id, id prefix, or label substring. Thread reconstruction keys
on the sender stamp (from), matched against the counterpart's box id (full
or prefix) or its resolved label.
Aggregates the whole spool into from └─▶ to ···· count adjacency rows,
busiest first — the shape of the fleet's chatter at a glance.
--json works on every view: overview dumps per-box
{id, label, live, pending, total, messages} (filters recount when present),
<id> dumps one box, --between dumps {a, b, count, messages}, --graph
dumps the edge list, and --watch streams NDJSON.
Every session is priced at scan time: cost_usd = Σ tokens × per-model price
and duration_ms = lastTs − firstTs are persisted on the session row (schema
v6). The price table is offline and versioned — no API calls, no telemetry —
covering current Claude, OpenAI, and Gemini models. Unknown/unpriced models
contribute $0, never NaN.
agents cost rolls those figures up across the local, cross-agent index:
# Daily $ histogram + top-10 sessions by cost + per-agent breakdown
agents cost
# Last 30 days, grouped by project instead of agent
agents cost --since 30d --by project
# Machine-readable daily rollup for a dashboard
agents cost --by day --jsonOutput sections:
- Daily — a zero-dependency unicode-block sparkline of $/day plus the priciest days.
- Top sessions by cost — the 10 most expensive sessions with short id, agent, topic, project, and wall-clock duration.
- By agent / project / day — grouped totals (
--by), summed cost, session count, and total duration.
agents cost is distinct from agents usage, which reports live
rate-limit / quota status per agent — different question, different command.
For per-session figures, agents sessions --json now carries costUsd and
durationMs, and agents sessions --sort cost|duration orders the list by
spend or wall-clock time (NULLs last).
# The 10 most expensive sessions, anywhere
agents sessions --all --sort cost --limit 10 --json | \
jq '.[] | {shortId, agent, costUsd, durationMs, topic}'agents view shows, per installed agent, who's signed in and (where the
provider exposes it) live quota. Two separate passes feed the row, joined by
a stable per-account key:
- Account identity —
getAccountInfo(src/lib/agents.ts) is local-only, no network. It reads each agent's on-disk credential and surfaces an email when one is readable, else a stable account id, else a baresigned in. - Usage bars — a separate network pass (
src/lib/usage.ts) fetches live quota and rendersS:/W:bars + plan. It's stale-while-revalidate (on-disk cache under~/.agents/.cache/, keyed per account: 2-min fresh, 24-h block) soagents view/agents runstay off the network on the hot path.
What each agent can surface is bounded by what its local credential actually contains — this is a data-availability limit, not a policy choice:
| Agent | Account column | Usage bars | How it's derived |
|---|---|---|---|
| Claude | email + plan | live (api.anthropic.com) |
email/plan/quota from the local OAuth credential + usage API |
| Codex | email + plan | last-seen (session logs) | email/plan from the auth JWT; quota parsed from the newest session's rate-limit event |
| Gemini, Grok | — | email read from the local auth file | |
| Droid | live (api.factory.ai) |
~/.factory/auth.v2.file is AES-256-GCM (key on disk at auth.v2.key); decrypt locally, read the email from the WorkOS access-token JWT. That same token authorizes GET /api/billing/limits for the three rolling rate-limit windows (5-hour → S, weekly → W, monthly, detailed-view only). |
|
| Kimi | id:<user_id> + tier |
live (api.kimi.com/coding/v1/usages) |
JWT carries no email — only an opaque user_id. Quota + membership tier come from the /usages endpoint. |
| Antigravity | signed in |
— | OAuth grant with no id_token — presence only. File ~/.gemini/antigravity-cli/antigravity-oauth-token, else macOS keychain / Linux libsecret (service gemini + user antigravity) |
| others | not signed in unless a credential exists |
— | default case: no detector |
Two deliberate boundaries worth knowing:
- Droid decrypts a local credential. We read the user's own credential to
show their own email — the same thing the
droidCLI does. If it can't be decrypted (akeyring-v2/legacy login with no on-disk key), the row falls back tosigned inrather than blanking. - Kimi and Droid usage never refresh the token.
agents viewis a read/inspect command, so it must not rotate the user's OAuth credential (rewriting the file, invalidating the single-use refresh token, racing a runningkimi/droid). An expired token simply falls back to the cached snapshot; each agent's own CLI refreshes on its next launch (Droid's token lives 24h). Droid surfaces thestandard(primary) rate-limit pool, not the freecorefallback pool.
The same fields are exposed programmatically via agents view --json
(email, accountId, plan, usageStatus, windows).
agents cost is the observability half — it tells you what you already spent.
Budget guardrails are the enforcement half: they estimate a run's cost
before it starts and can block it, and — for local headless agents run —
attribute live spend and hard-kill the running agent the moment a cap is
crossed. Observability can't reach back in time and stop the call that blew
the budget; this can.
Scope (v1). The pre-flight estimate/block applies to agents run, agents teams, and agents cloud. The live mid-run hard-cap kill currently applies
to local agents run headless runs only; teams and cloud dispatch are gated
pre-flight (estimate + block before spawn) — live mid-run kill for
teams/cloud is a planned follow-up.
The guardrail is cross-vendor by construction — one cap spans every agent the CLI dispatches (Claude + Codex + Gemini + …), which no single-vendor control can do.
Add a budget: block. It resolves project > user (same precedence as
run:): a project's agents.yaml overrides your user-global caps field by
field. Every cap is in USD.
budget:
currency: USD
per_run: 5.00 # cap on a single run's estimated/actual cost
per_day: 50.00 # cap on total spend today (ALL agents)
per_project: 100.00 # cap on cumulative spend for this project
per_agent: # per-agent daily caps
claude: 30.00
codex: 20.00
on_exceed: block # block (refuse / kill) | warn (proceed, report)
require_confirm_over: 1.00 # prompt before a run estimated at or above thisA cap is enforced only when set; an empty budget: block leaves the feature
dormant (zero overhead). on_exceed defaults to block (fail-closed).
Every agents run prints an estimate and, under on_exceed: block, refuses to
launch when a cap would be breached — exiting non-zero (code 2) so CI,
headless runs, teams, and cloud dispatch all inherit the decision.
$ agents run claude "big refactor across the repo" --model claude-opus-4
[budget] est. $2.48 for this claude run (claude-opus-4, prompt size)
[budget] BLOCKED: estimated $2.48 exceeds per_run cap $0.01
Raise the cap in agents.yaml budget: or set on_exceed: warn to proceed.
$ echo $?
2The token basis comes from recent ledger averages for the same agent, falling back to a prompt-size heuristic when there's no history.
-y / --yes skips the interactive require_confirm_over prompt for scripts,
but never skips a hard block — a cap breach blocks regardless of --yes.
For local non-interactive (-p / --print / headless) agents run
invocations, spend is parsed off the agent's stdout stream as it happens and
accumulated against the caps — this is attached whether or not output is being
piped (the child's stdout is captured and tee'd back so you still see it). The
moment a cap is crossed the child is terminated (SIGTERM, then SIGKILL after
5s — the same mechanism as --timeout) and the run resolves with a distinct
exit code (7) so a budget kill is distinguishable from a normal failure or a
timeout. Final spend is written to the shared ledger.
Interactive REPL sessions are not live-killed (the human owns the TTY); they
rely on the pre-flight gate. agents teams teammates and agents cloud
dispatch are also not live-killed in v1 — they are gated pre-flight only. Live
mid-run kill for teams/cloud is a planned follow-up.
Every run that produces token usage appends to an append-only JSONL ledger at
~/.agents/.history/spend/ledger.jsonl. Each line attributes one usage
observation to { runId, agent, project, day, model, tokens, costUsd, source }.
This is the shared artifact agents cost can read for $ rollups.
agents budget # caps + spend-to-cap bars (today + project)
agents budget --json # machine-readable snapshot
agents budget set per_run 5 # write a user-global cap
agents budget set per_agent.claude 30
agents budget set on_exceed warn # switch to warn-only (do not block)agents budget reports the effective merged config for the current
directory. set writes the user-global layer; project caps are hand-edited in
the repo's agents.yaml.
External tools observing live sessions should know about these env vars, set automatically on agent terminal spawns:
AGENT_SESSION_ID # Session UUID - matches sessions.id in the DB
AGENT_TERMINAL_ID # Internal tracking ID (CC-<ms>-<n>)
AGENT_WORKSPACE_DIR # cwd for the agent
Reading these from a VS Code / tmux / process tree lets you answer "which running process owns this session?" without re-parsing state.
- Sub-100ms read budgets. Each
agentsinvocation is a Node.js process spawn. Read the DB directly withbetter-sqlite3at~/.agents/.history/sessions/sessions.db— but you give up schema migration safety. - Push-based notifications. The JSON sources are pull-only. For real-time events, tail the session JSONL files directly or use agent-native SDKs.
- Writing state. Observability is read-only. To spawn agents, use
agents run,agents teams add, oragents cloud run.
- Sessions — the
sessionssubsystem in depth - Cloud dispatch (
agents cloud --help) - Team DAGs (
agents teams --help)