primcli
v1.58.0
Published
Official Primitive CLI: deploy Primitive Functions, send and inspect mail, manage endpoints, all from the terminal. Wraps the @primitivedotdev/sdk runtime client with one-shot commands.
Maintainers
Readme
primitive
Official Primitive CLI. Deploy Primitive Functions, send and inspect mail, manage endpoints, all from the terminal.
brew install primitivedotdev/tap/primitive
primitive whoamiOr with npm:
npm install -g primitive
primitive whoami
# `prim` is installed as a short alias for the same CLI.
prim whoamiThe same CLI is also published as primcli and under the legacy scoped name @primitivedotdev/cli. Each installs an identical build with the same primitive/prim commands. Use whichever name you prefer; they track the same version.
Or with no install:
npx primitive@latest <command>This package wraps the @primitivedotdev/sdk runtime client with one-shot commands. For in-handler use (calling Primitive from inside a Function), import createPrimitiveClient from @primitivedotdev/sdk/api directly; the CLI is for operator and deploy workflows.
Quickstart
Receive webhook events without a public endpoint
With an existing Primitive account and inbox, sign in or set PRIMITIVE_API_KEY.
Run primitive listen to print existing webhook events as JSONL.
--json is accepted explicitly; status remains JSON and stdout events remain JSONL. Status goes to
stderr. No public URL or separate destination setup is required.
For an agent, use a short hook that saves each event into its durable inbox:
primitive listen init --language python
cd primitive-listener
primitive listen --subscription my-agent --exec 'python3 accept_event.py'The starter works locally and refuses to overwrite an existing directory. Its
SQLite inbox is application-owned example code. Replace accept_event with your
agent's existing durable input function. Exit 0 means the input was saved; run
model work separately so the listener can keep receiving while the agent thinks.
The hook has a 30-second deadline. Do not spawn a background agent from the hook.
--exec requires macOS or Linux for process-group cleanup. On Windows, use
--forward-to with your agent's local HTTP acceptance handler.
The CLI passes the unchanged event body on stdin, plus PRIMITIVE_EVENT_ID,
PRIMITIVE_DELIVERY_ID, and PRIMITIVE_EVENT_TYPE in the hook environment.
Deduplicate by PRIMITIVE_EVENT_ID, which stays the same on redelivery. Delivery
IDs identify individual attempts. Related events need not contain an email or a
body-level event ID. The generated processing example shows existing Python SDK
conversation and reply calls; sending requires an explicit choice.
To run an existing HTTP webhook handler locally:
primitive listen --subscription local-webhook --forward-to http://localhost:3000/webhookThis mode forwards the exact signed body and existing webhook headers. Configure the handler with your existing account webhook secret and normal signature verification. The CLI never forwards your API credentials. It does not follow redirects or disable TLS verification. A response must finish within 30 seconds and remain within 1 MiB. Existing confirmation headers retain their meaning, including account content-discard behavior; exec and stdout do not confirm content discard.
Use --events email.received,interaction.ack.received to select events on a new
subscription. Omitting --events when resuming preserves its selection. A
conflicting selection requires a different subscription name. Ctrl-C disconnects
without deleting the destination; restart with the same name to receive pending
events. On the same machine, a second listener cannot consume that subscription
while the first holds it. The same name on different machines shares consumption;
different names receive independent copies. With no name, the CLI saves a private
default identity scoped to your API host and account.
--number N exits after N successful, server-confirmed completions. A crash after
the hook saves input but before completion can redeliver it. Bare stdout is for
inspection: a successful pipe write does not prove a downstream agent saved the
event. Use the durable hook for ingestion.
The server retains pending references for 24 hours, subject to source-content
availability and queue capacity. The listener reports delivery gaps instead of
silently claiming it is caught up. Saving download links does not preserve
attachment bytes or extend their expiry. To remove a destination, use its ID from
the readiness message: primitive endpoints delete --id DESTINATION_ID.
Low-level delivery API
The generated endpoints pull-webhook-event and endpoints complete-webhook-event
commands expose the local-delivery API when enabled on your API host. These are
single API calls; a receive loop must retain the destination identity and report
each attempt's outcome. Use --help to inspect the request fields:
primitive endpoints pull-webhook-event --help
primitive endpoints complete-webhook-event --helpCompletion uses a mode-specific JSON body. For a successful process handler:
primitive endpoints complete-webhook-event --id ENDPOINT_ID --raw-body '{"queue_id":"QUEUE_ID","delivery_id":"DELIVERY_ID","lease_token":"LEASE_TOKEN","mode":"exec","exit_code":0,"duration_ms":12}'Use the IDs and lease token returned by pull. HTTP forwarding reports
mode: "http" with status_code; stdout reports mode: "stdout" with
write_succeeded. Transport failures must also be reported. Successful handler
acceptance is separate from any later agent reasoning.
Pull delivery preserves existing webhook bodies and supplies canonical event and attempt IDs separately. Deduplicate by event ID. A successful completion can be retried with the same queue, attempt, token, and outcome. Check the returned gap count as well as the backlog; queue retention does not extend email-content retention. Conversation retrieval and threaded replies use the existing SDK API.
primitive signin
primitive whoami
primitive functions templates
primitive functions init my-fn
cd my-fn && npm install && npm run build
primitive functions deploy --name my-fn --file ./dist/handler.js
primitive send --to [email protected] --body "Hello!" --wait
primitive emails latest --limit 5
primitive inbox nextRun primitive --help for the full command list. Per-command help (primitive functions deploy --help) carries enough detail that an agent can compose any operation without leaving the terminal.
Waiting with connected credentials
After sending, wait for the existing send's reply without sending again:
primitive emails wait --reply-to-sent-email-id <sent-id> --from [email protected]The CLI reads that sent record to derive your receiving address. Optional --to
must match it. The peer stays explicit because sent records do not expose a
complete recipient inventory. Connected waits include existing replies by
default, so fast replies are not missed; use --since <timestamp> to narrow the
window. They require an authenticated peer and the exact outbound parent.
Interaction attachments remain pending with inspection guidance. JSONL includes
matching email details, or use --table for compact output and --number N for
multiple replies. Timeout exits 1; repeat the same wait to recover without
resending. Connected waits share one local address event receiver and recover
through exact-parent search, without scanning inbox history. Additional content
filters on this command require organization credentials.
Connected primitive chat <peer> <message> --from <own-address> registers its
reply wait and authenticates the receiver before sending. It records an explicit
idempotency key before the request. If the send response is lost, repeating the
same command looks up that key without sending again. A timeout retains the exact
parent claim for resume. A plain reply is an email response, not proof that a task
is complete. The subject is short: the message's first sentence, cut at a word
boundary to at most 60 characters. The whole message is the body.
Authentication
Use primitive signin or primitive login for existing accounts. With no email, both use browser approval; primitive signin browser and primitive login browser are the explicit browser forms.
Use primitive signin <email> --accept-terms, then primitive signin confirm <email> <code> for email-code sign-in. primitive login <email> and primitive otp <email> support the same email-code flow with matching confirm and resend subcommands.
Use primitive logout --force to remove local CLI credentials, pending email-code auth state, and stale credential locks without contacting Primitive. This is the recovery command when an interrupted auth command leaves the CLI saying another credential operation is already in progress.
Use primitive signup <email> for new account creation, then primitive signup confirm <email> <code> with the emailed verification code. Non-interactive signup is available with --accept-terms.
Reply state and the agent loop
Every inbound email carries reply state: awaiting is you when the latest
message in its thread is inbound (it waits on your reply) and them when you
replied last; reply_count and last_replied_at describe replies to that one
email. A reply counts once it is sent or committed to go (queued and scheduled
count; gate-denied, agent-failed and canceled sends do not).
primitive emails latest shows it as the AWAITING and REPLIES columns, and
--json carries the fields. Filter on it with --awaiting you|them on
emails latest, emails list, emails search, emails wait, emails watch
and search, or with awaiting:you in a search query.
primitive inbox next returns the oldest email awaiting your reply, its
conversation (roles user and assistant; the API caps long threads and sets
truncated when older messages are omitted), an automated verdict with
reasons, and the exact primitive reply --id <id> command that answers it. The
loop is:
primitive inbox next --json > next.json # exit 5: nothing awaits you
primitive reply --id "$(jq -r .email.id next.json)" --body "..."
primitive inbox next --json # the next one| Exit | Meaning |
|---|---|
| 0 | An email awaits your reply; it is printed. |
| 1 | Error, including reply_state_unsupported or automated_filter_unsupported from an older server. |
| 2 | Invalid flags or arguments. |
| 5 | Nothing awaits your reply (with --wait: still nothing at --timeout). |
- Automated mail is skipped unless you pass
--include-automated: null envelope sender (bounces), mailer-daemon and postmaster, mail sent from the very address it was delivered to, delivery, feedback and disposition reports, and mail that declares itself automated (Auto-Submitted, Precedence bulk/list/junk, List-Unsubscribe, List-Id, X-Auto-Response-Suppress, X-Failed-Recipients). This is the same set Primitive's own automatic responders decline to answer. The API decides this when the mail arrives (automated,automated_reasonson every email) andinbox nextfilters on it server-side (awaiting=you&automated=false), so a call costs the same however much unanswered automated mail has piled up. On an empty result,automated_awaitingsays how much automated mail also awaits.automation_headers_known: falsemeans the email had no automation headers on record (none declared, or received before they were captured), soautomated: falserests on the sender checks alone. Filter on the verdict yourself with--automated true|falseonemails list,emails search,emails waitandemails watch, orautomated:falsein a search query. - The
awaitingfilter covers delivered mail only: mail the server rejected (for example over the storage limit) was never delivered and is never returned as awaiting you. A server whose filter still returns rejected mail fails withawaiting_rejected_unsupported. --wait [--timeout N]blocks until something awaits you (default 300 seconds, 0 waits forever). It reads the inbox's newest position before checking reply state, then long-polls from that position and re-checks on every arrival and at least every 30 seconds, so mail that lands between the check and the wait is not missed. Mail that arrives while you compose a reply is stillawaiting=youon the next call, because the state lives on the server, not in a cursor.- The email carries
from_known_addressandauth(SPF, DMARC) so an agent can weigh instructions in it; the transcript prints them and strips terminal control sequences from sender-supplied text. - It is not a work queue. Nothing is claimed or locked, so two agents calling
inbox nexton the same inbox get the same email until one replies. Run one agent per inbox. - Against a server without the
automatedfilter,inbox nextfails withautomated_filter_unsupportedrather than deciding automated mail itself and re-reading all of it on every call;--include-automatedstill works there. - Against a server that does not report reply state,
inbox nextand every--awaitingfilter fail withreply_state_unsupportedinstead of treating mail as unanswered.
Command style
Use task-oriented commands for normal workflows:
primitive send --to [email protected] --body "Hello"
primitive reply --id <inbound-email-id> --body "Thanks"
primitive reply --id <inbound-email-id> --body "See attached" --attachment ./report.pdf
primitive reply --thread <thread-id> --body "Answering the latest message"
primitive reply --id <inbound-email-id> --fyi --body "Merged. No action needed."
primitive chat reply "See attached" --attachment ./report.pdf
primitive emails list
primitive emails get --id <inbound-email-id>
primitive emails get --id <inbound-email-id> --brief
primitive sent list
primitive sent delete --id <sent-email-id>
primitive domains list
primitive functions templates
primitive functions init my-fn --template email-reply
primitive functions logs --id <function-id>
primitive memories set thread:latest '{"email_id":"em_123"}'
primitive memories get thread:latest
primitive deliveries replay --id <delivery-id>Generated API commands remain available for compatibility and full schema parity, for example primitive emails:list-emails and primitive sending:reply-to-email.
Send outcomes and exit codes
primitive chat, primitive chat reply, primitive send and primitive reply
report the same outcomes. Exit codes tell you whether a message left and whether
sending again is safe. With --json, stdout is an envelope for every outcome
(failures included) whose outcome field carries the name. The envelope always
has sent_email_id (null until a send record is known) and idempotency_key,
including when the outcome is uncertain.
| Outcome | Exit | Meaning |
|---|---|---|
| replied | 0 | Chat only: the message was sent and a reply arrived. |
| sent | 0 | Accepted for delivery. status: "queued" is a success, not a pending failure. |
| already_sent | 0 | The server recognised an identical earlier send, or refused with HTTP 410 sent_email_deleted because that earlier send was deleted. Nothing new went out. Do not resend. |
| not_sent | 1 | The API rejected the request (HTTP 400, 401, 402, 403, 404, 413, 422 or 429), the command failed before sending, or the send record has status agent_failed, gate_denied or canceled. Nothing went out. |
| (usage error) | 2 | Invalid flags or arguments. Nothing went out. |
| sent_awaiting_reply | 3 | Chat only: the message was sent but no reply arrived before --timeout. Wait with the printed command; do not resend. |
| uncertain | 4 | Transport error, conflict, server error, or a send record with status unknown. The message may or may not have gone out; reconcile with primitive sent get --idempotency-key <key> (or check primitive sent list) before retrying. |
A chat that times out prints Message sent (id X). No reply yet after Ns. Do NOT
resend; wait with: <command>, and its --json envelope has "reply": null, the
sent record, and follow_up_commands that only wait on or inspect that send.
Every send carries an idempotency key. Pass your own with --idempotency-key, or
let the CLI derive one from the message content, so an identical retry is still
deduplicated. If an outcome is uncertain, or you lost the output, reconcile by
key instead of resending:
primitive sent get --idempotency-key <key> --jsonIt prints the newest send with that key. When nothing matches it exits 1 with
error code not_found. That means no record is visible yet, not that the attempt
created nothing: a send can still be in flight, or its record can have been
deleted while the key stays reserved. If you retry, retry with the same
--idempotency-key, never a new one.
A key the CLI derives itself covers the request content and the current
five-minute window, the same window the API uses when a request carries no key.
An identical send inside that window is deduplicated, a deliberate repeat later
still goes out, and different replies to the same email are separate sends. To
retry an uncertain send after the window, pass the key it reported with
--idempotency-key.
Without --json, send and reply keep printing the send record on stdout exactly
as before and add a one-line stderr summary such as Reply sent (queued for
delivery, id X). Do not resend. Before sending, primitive reply (and
primitive chat --reply) warns on stderr when the inbound email already has a reply
that went out. The warning never blocks the send; if the lookup fails, the reply is
still sent and stderr says the check was skipped. --json includes the replies as
prior_replies.
Replying to the latest message in a thread
primitive reply --thread <thread-id> answers the newest inbound email in the
thread instead of a specific one, so an older message is never answered while
newer ones wait. It uses the thread's latest_inbound_id when the API returns
it and otherwise the newest inbound entry in the thread's message list. Without
--json, stderr names the email that was answered; with --json, the envelope
carries reply_target: { thread_id, email_id, resolved_by }. --id and
--thread are mutually exclusive.
Informational replies
primitive reply --fyi sends a reply that needs no answer. It goes out as an
ordinary threaded reply carrying an ack signal (status received, see
optional email signals) whose note is the
plain-text body:
Received your message.
<your body>Receivers that classify signal content treat it as informational and do not
wake for it. --fyi takes plain text only: no HTML or attachments, at most 2000
characters, and trailing whitespace is dropped. With no body the reply is the
bare acknowledgement. It is refused when the email being answered is itself a
signal or interaction, so two agents cannot keep acknowledging each other.
primitive send --fyi --in-reply-to <message-id> sends the same kind of
acknowledgement for a message identified by its Message-Id.
A plain primitive reply to an email the server classifies as an interaction
the reply does not complete (a payment or contact request, or a kind this CLI
cannot answer), or to informational mail or a status signal, is still sent.
Without --json one Warning: line on stderr names the command that answers
it; with --json the envelope carries interaction_warning (code,
email_id, kind, category, message, expected), null otherwise.
An informational reply or send carries an idempotency key like any other
send, derived from the target and the note, so retrying the same command is
deduplicated. With --json the envelope reports it as idempotency_key.
JSON output
With --json, stdout is exactly one JSON document, on success and on failure,
and stderr stays empty. Output merged with 2>&1 therefore still parses:
- Notices the command would otherwise print on stderr (hints, progress, prior
reply warnings) go in the document's
warningsarray. - A failure adds
errorandexit_code. If the command printed no document of its own, the CLI prints{ "error": ..., "exit_code": ... }. - Generated API commands (
primitive sent list,primitive emails list, ...) keep printing only the data payload with--json, the same stdout as without it, but do not write thenext cursor: <cursor>line to stderr. To page, add--envelope: it prints the full response envelope, withmeta.cursorfor the next page and empty-result hints insummary. - Commands whose
--jsonoutput is a bare array keep that shape. primitive listenstreams JSONL and is not covered by this rule.
Remove mailbox history
primitive sent delete --id <sent-email-id> removes sender history and owned
attachments. It does not recall delivery or delete recipient copies. Cancel
scheduled sends before deleting them. A 409 means the record is not currently
eligible; a 500 or 503 may mean some files were removed already, so retry the same
DELETE. Repeating a completed deletion succeeds.
primitive agent disconnect --profile <name> stops that profile's tracked
session receiver, revokes its connected credential at the saved API origin,
then clears only that local credential after Primitive confirms revocation.
Mail, notes, setup evidence and notification receipts remain. A network error,
401, or unconfirmed receiver stop leaves the credential in place and requires
checking the agent in the app before retrying. Foreground or external runtime
hooks are not managed by this command.
After disconnecting an agent, an owner or admin logged in with OAuth can run
primitive agent-connections remove-agent-connection --address [email protected].
This removes the revoked connection record while preserving mail, notes and the
external runtime. Active connections return 409; missing records return 404.
Organization API keys cannot remove connections.
Send and reply can return HTTP 410 sent_email_deleted when a prior send was
deleted. Its occupied idempotency key or automatic reply suppression stays reserved.
Do not generate a new key or send again to bypass this refusal.
Primitive Memories
Memories are durable JSON key-value records scoped to your org by default. Use
--function <function-id> to read or write the same key under a function scope;
the value is the function id UUID, not the function name.
primitive memories set thread:latest '{"email_id":"em_123"}'
primitive memories set greeting '"hello"'
primitive memories get thread:latest
primitive memories search thread: --metadata-only
primitive memories delete thread:latest
primitive memories set state '{"step":2}' --function <function-id>
primitive memories get state --function <function-id>Values must be valid JSON. Strings must be quoted as JSON strings, so use
'"hello"', not hello.
Credits
Redeem a credit code for your organization and check the credit balance.
primitive credits redeem LAUNCH50
primitive credits balance
primitive credits balance --jsoncredits redeem prints the credit added and its expiry. On a refusal it prints
the server's message (for example an invalid or already redeemed code) and
exits non-zero. Redeeming needs an organization owner or admin; with an API key,
the key's creator must currently be an owner or admin. Each run sends a new
Idempotency-Key; pass --idempotency-key <key> to retry the same redemption
safely. On any failure the command prints the key it used to stderr, so you can
run the same command again with --idempotency-key <key>.
Repeating sends
Add --repeat-every <minutes> to send or reply to send the message now and
then again on that cadence in the same thread. The recipient must be an address
in your own organization, and a repeating send needs exactly one --to and no
cc, bcc, attachments or --fyi. Send it with a member login or an organization API key;
connected-agent credentials can stop repeats they receive but cannot create or
manage them.
primitive send --to [email protected] --body-file prompt.txt --repeat-every 60
primitive reply --id <email-id> --body "Any progress?" --repeat-every 30 --only-if-idle 15
primitive send --to [email protected] --body "Status?" --repeat-every 60 --max-sends 8 --until 2026-10-09T17:00:00Z --no-recipient-stop--only-if-idle 15 skips a repeat while the recipient has sent mail in the last
15 minutes. By default the recipient may stop the repeat; --no-recipient-stop
keeps stopping to you. The send result names the repeat id.
primitive repeats list --to [email protected]
primitive repeats get <repeat-id>
primitive repeats pause <repeat-id>
primitive repeats resume <repeat-id>
primitive repeats cancel <repeat-id>The recipient stops a repeat when it is no longer needed by passing the repeat id from the message footer, or the id of any received message of it:
primitive repeat stop --id <email-id> --reason "The report is finished"The sender sees the stop in the thread. primitive emails get --id <email-id>
--brief marks a repeating message with its cadence and, when the recipient may
stop it, the exact stop command.
Recipient routing
Bind a recipient address to a destination so inbound mail resolves to a single
endpoint. Pass --function to route an address to a function (its route-target
endpoint is created in the same call, enabling per-address routing like
[email protected] -> functionA), or --endpoint for an existing endpoint.
primitive routes add [email protected] --function <function-id>
primitive routes add 'support+*@acme.com' --match wildcard --endpoint <endpoint-id>
primitive routes list
primitive routes test [email protected] # preview where an address resolves, with the rule trace
primitive routes update <route-id> --priority 5
primitive routes reorder --set <route-id>=10 --set <other-id>=20
primitive routes remove <route-id>Recipient routing is gated by an organization entitlement; routes are inert until it is enabled.
x402 payments
The primitive payments command group drives non-custodial x402 USDC payments. One agent registers a payout address and requests a payment; the paying agent signs locally with its own wallet key and settles. The key never leaves your machine. Networks are base and base-sepolia. Amounts take a human USDC value (--amount-usdc 0.01) or token base units (--amount 10000, since USDC has 6 decimals). Your org is resolved automatically from your API key, so payout registration takes no org flag.
# Payee, one time: register the default address your org is paid at. Signs an
# ownership message locally with your wallet key. Org is auto-resolved.
primitive payments register-payout-address --network base-sepolia --label treasury
# Payee: request a payment with a human USDC amount. Prints the challenge JSON
# to stdout; a one-line summary goes to stderr.
primitive payments charge --network base-sepolia --amount-usdc 0.01
# Capture the challenge JSON to hand to the payer.
primitive payments charge --network base-sepolia --amount-usdc 0.01 > challenge.json
# Payer: sign and settle the challenge locally. Reads the challenge inline,
# from a file, or piped on stdin.
primitive payments pay --challenge-file challenge.json
cat challenge.json | primitive payments pay
# Email-native flow: the payee issues a challenge over an email thread.
# Note: create-email-challenge takes --amount in token base units only; unlike
# `charge` it has no --amount-usdc. USDC has 6 decimals, so multiply by
# 1,000,000: 0.01 USDC is --amount 10000.
primitive payments create-email-challenge --from [email protected] \
--to [email protected] --amount 10000 --network base-sepolia
# Payer (recommended): pay the email challenge in one step. Signs the challenge
# locally with your wallet key AND sends the signed interaction.json, so you
# skip the manual sign-then-send dance. --in-reply-to is the inbound challenge
# email you received; it is fetched to address the payment to the payee, with
# From defaulting to the payer it was sent to. The send is not threaded under
# the challenge (the payment associates by interaction_id). The message carries
# a short default note alongside the attachment; pass --body to customize it.
primitive payments pay-email --challenge-file challenge.json \
--in-reply-to <inbound-challenge-email-id> --wait
# Advanced: sign only, without sending. Emits the portable interaction.json
# artifact you can attach yourself (e.g. with `primitive send --attachment`).
primitive payments pay-email-step --challenge-file challenge.json > interaction.json
# Inspect a challenge by id, or list your registered payout addresses.
primitive payments get-challenge --id <challenge-id>
primitive payments list-payout-addresses
# Read and update the org spend policy (kill-switch, per-payment and daily caps,
# payee allowlist). The update merges: omitted fields keep their value.
primitive payments get-spend-policy
primitive payments update-spend-policy --max-per-payment 5000000charge is the friendly verb that matches the SDK charge and accepts --amount-usdc; create-challenge is the lower-level command that takes base-unit --amount. Either creates a challenge. All four signing commands accept --json. register-payout-address and pay print a human-readable summary by default and raw JSON with --json; pay-email and pay-email-step print JSON by default (the send result and the interaction.json bytes, respectively), and --json switches them to a fuller envelope object.
The signing commands (register-payout-address, pay, pay-email, and pay-email-step) need your wallet key. pay-email is the recommended payer path for the email-native flow: it signs and sends in one step. pay-email-step signs only and emits the interaction.json artifact for advanced use where you want to deliver it yourself. Set the key in PRIMITIVE_X402_PRIVATE_KEY (a 0x-prefixed hex private key) so it never lands in shell history or the process list:
export PRIMITIVE_X402_PRIVATE_KEY=0x...A --private-key flag is available as an escape hatch for scripted use, but the environment variable is preferred. The non-signing commands (charge, get-challenge, list-payout-addresses, get-spend-policy, update-spend-policy) need only your Primitive API key. Run primitive payments <command> --help for the full flag list of any command.
Migrating from @primitivedotdev/sdk CLI
The CLI previously shipped inside @primitivedotdev/sdk. The shipped surface area is identical; only the package name changes.
| Before | After |
|--------|-------|
| npm install -g @primitivedotdev/sdk | npm install -g primitive |
| npx @primitivedotdev/sdk@latest <cmd> | npx primitive@latest <cmd> |
@primitivedotdev/sdk continues to ship the runtime SDK (webhook, API client, contract, parser, openapi). Use it in your application code; use primitive in your shell and CI.
License
MIT
Local event listening
primitive listen --forward-to localhost:3000
primitive listen --forward-to 3000 --events email.received
primitive listen --once --timeout 60
primitive listen --subscription my-agent --exec "python3 accept.py"WebSocket is the default transport. Subscription registration and reconnects are
automatic; the saved default resumes the same durable queue. --once waits for
one successful, confirmed delivery. --timeout is in seconds and exits 2 on
timeout; Ctrl-C exits 130. Bare primitive listen prints one raw JSON event per
line. Use --transport poll explicitly for HTTP polling. Accept or enqueue each
event within 30 seconds. Closing preserves pending work, and retries can deliver
an event more than once.
Native session email notifications
With a connected-agent credential already configured, notify one exact loaded Codex session of authenticated mail from explicitly approved senders:
primitive listen --notify-session <session-uuid> --sender [email protected]
primitive listen --notify-session <session-uuid> --sender [email protected],[email protected]
primitive listen --background --notify-session <session-uuid> --contacts --contact-requests
primitive listen --status --notify-session <session-uuid> --json
primitive listen --status --notify-session <session-uuid> --email-id <received-id>
primitive listen --status --notify-session <session-uuid> --limit 100 --cursor <nextCursor>
primitive listen --stop --notify-session <session-uuid>Notifications arrive as external primitive.mail_received tool-output events,
never synthetic user messages. An idle session can wake to evaluate the notice;
a busy session receives it in its active turn. The notice does not grant user
authority or permission to execute requests from email.
This notification path uses turn/start with empty input and toolOutput over
the native local-session Unix socket in Codex. It requires runtime support for
external tool-output turns and has no user-message fallback. Live runtime behavior was verified on macOS; Linux uses the same
Unix transport but has not been verified against a live runtime. The session must already be open in a terminal with
native daemon support enabled, with compatible client and server versions.
The foreground command exits if its socket is unavailable; a background receiver
reports reconnecting and waits for a temporarily unavailable native socket.
Neither creates a subscription before native preflight succeeds. The CLI does
not launch a coding daemon, start/resume a conversation, install a
connector, or change model, approval, or sandbox settings. Windows and other
harnesses are not supported by this path. CODEX_HOME selects the native runtime
home when it differs from ~/.codex.
--background starts one detached CLI process for this connection and exact
session. It survives exit of the process that started it. Repeating the command
reuses a live background receiver with the same CLI version and receiving options.
Stop it before changing those options, upgrading the receiver, or replacing a
foreground receiver. --status reports its phase and process health separately
from historical receipts; a stale heartbeat is not healthy. --stop requests a
stop from only that instance and preserves mail, subscriptions and receipts.
Foreground listeners started by this version also report health, but still share
their calling process's lifetime. Receivers from older versions are untracked.
Failed receivers include a fixed failureCode (for example disk-full or
crashed) and a detail sentence; private error contents are never stored.
Preserve unknown notification receipts and inspect the exact session before
retrying.
A supervisor restarts the receiver after every unexpected exit, with backoff
that settles at one attempt every five minutes rather than giving up. A full or
failing disk does not stop it: missed heartbeats show as stale until writes
succeed again. If the supervisor itself dies, its receiver starts a
replacement. If both are gone, the next primitive command run from the same
session (with PRIMITIVE_AGENT_PROFILE selecting the connected profile) starts
the receiver again in the background, at most once a minute. A receiver held
for an unknown notification outcome or a changed connection, or stopped with
--stop or agent disconnect, is never restarted this way. Set
PRIMITIVE_RECEIVER_HEAL=0 to turn this off.
Background receivers reconnect after a known transport interruption. A previously verified session may temporarily be unloaded while its terminal reconnects; the receiver waits for that same session before subscribing to it. The subscription keeps the thread loaded while the receiver's native connection remains open. Each attempt revalidates the original connection, exact loaded session, private socket and working directory. Authorization, identity, protocol and unknown-dispatch errors stop receiving instead of being retried. Reconnection never starts a missing session or grants tool authority. A healthy receiver is not proof of a model reply.
Notification mode registers an email.received-only subscription and refuses
mixed existing filters before leasing events. Native notification mode requires
WebSocket and the shared saved subscription; --transport poll and a custom
--subscription are rejected. Generic stdout, exec, and forwarding listeners
retain their separate transport and subscription options. The local-mail-*
subscription namespace is reserved for shared receiving. An unexpected non-email
event remains uncompleted. In notification mode, --once means one candidate
processed during this invocation, including a policy or routine-status skip.
Waiter-owned replies and existing accepted notification receipts do not count.
It does not promise one session notification.
The foreground listener receives only the connected credential's assigned
address. --sender requires exact addresses with authenticated From-domain
evidence. Domain authentication does not independently prove a person's identity.
Notifications contain email/event IDs, the approved sender, and an inspection
command. Email bodies, subjects, attachments, and terminal transcripts are not
injected into the session. Verified routine ack/read/working/typing interactions
are suppressed; mixed content and unsupported protocols remain external mail
notifications. Inbound IDs are saved before the server delivery is acknowledged.
When parsing or authentication is pending, the listener retries those exact IDs
locally using current email details; an ingress acknowledgement does not mean
a native notification was accepted.
Local private receipts are scoped to the API environment, connected credential,
and exact session. Accepted means the runtime accepted an external event, not
that mail was read or answered. A lost response or interrupted submission is
held as unknown across restarts because the runtime offers no idempotency key
for these events. Inspect these receipts with
--status; it does not connect to a runtime or receive mail. Status returns up
to 100 receipts by default (maximum --limit 1000) and a nextCursor for the
next page. Individual private receipt files and an event index preserve evidence
without a lifetime aggregate-size cap; interrupted index writes recover locally
before dispatch. Unknown outcomes require inspection of that exact session before a manual resend. Do not delete
receipt state to force a retry. Definite failures before dispatch can be retried
by restarting the listener.
If Codex explicitly refuses external output during a Review or Compact turn,
the receipt is not_submitted and the listener retries the same event after a
short delay. A timeout or disconnect is still unknown and is not resent.
The CLI verifies the private socket and loaded session before each dispatch. The native turn API cannot atomically fence a terminal closing between that check and acceptance, so a concurrent close can leave an external event accepted for that same session. The CLI never retargets a different session.
Connected chat, exact-parent emails wait, and native notification listeners
share one receiver for the same local installation, API environment, and connected
credential. Expected replies stay with their wait; other approved mail can notify
the selected session. Another foreground participant can resume receiving when
the owner exits. Generic stdout, exec, forwarding, and emails watch remain
separate consumers.
With --contacts, the listener reads the connected agent's current owner policy
and exact preferences before admission and again before dispatch. Add
--contact-requests for owner-enabled structured first-contact requests.
Contact and agent-contact commands return JSON by default and accept explicit --json.
Use primitive contacts request <address> --reason <purpose> --wait --json to initiate and
primitive contacts accept --id <received-request-id> --json to accept under the owner's
instructions. Request acceptance is separate from a substantive task reply. See
contact requests and policy for approval patterns,
policy CLI commands, explicit --notify consent, and recovery.
Agent address notes
Connected profiles default to their own address. They can read another address's
organization notes with --address, but can write only their own, and the
server refuses note deletion from a connected-agent credential.
Owner logins must pass --address.
primitive agent notes list
primitive agent notes get AGENT_INFO
primitive agent notes list --address [email protected] --prefix AGENT_
primitive agent notes set AGENT_WORKING "Researching the requested topic"
primitive agent notes set AGENT_INFO --value-file agent-info.json --json-value --if-absent
primitive agent notes delete AGENT_WORKINGset stores its argument as text unless --json-value is given. Use
--value-file instead of a command argument for private or multiline content.
New notes are private to the organization. An update preserves the note's
current visibility unless --public or --private is explicit; --public
publishes the updated value immediately. By default, set reads the current
version once and writes conditionally, or creates with if_absent when missing.
Use --if-version <version> or --if-absent to provide the condition directly.
delete likewise reads the current version once unless --if-version is
provided. Conflicts are never retried automatically.
Sending from another session's profile
send, reply and chat warn when the connected agent profile was set up in
a different Claude Code or Codex session than the one running the command:
This profile belongs to another session (<short id>); sending as <address>.
The warning goes to stderr, or to warnings with --json, and the message is
still sent, since reusing a profile can be intended.
Work claims
A work claim says what an agent is changing right now, so peers can check it
before editing a shared file. It is one short line naming the task and the
files or areas being changed, stored with an expiry in the AGENT_WORKING
address note as JSON {"claim": "...", "until": "<ISO time>"}. Claims are
advisory, not locks.
primitive agent working set "phone composer: apps/mobile/src/message-composer.tsx"
primitive agent working set "billing export: src/billing/" --until 2026-10-01T18:00:00Z
primitive agent working get --address [email protected]
primitive agent working clearSet a claim when work starts and clear it when work ends. Without --until, a
claim expires 4 hours after it is set. clear rewrites the claim with its
expiry set to now, so it reads as none from then on; if that write is refused
it deletes the note instead, when the credential is allowed to. --json reports
{ address, cleared, method } with method expired, deleted or null. get prints the claim and its expiry, or
none when there is no claim or it has expired; a plain-text value written
without an expiry is shown as-is. --json prints { address, state, claim,
until } where state is active, legacy or none. Address rules and
visibility follow agent notes: new claims are private to the organization and
an update keeps the note's visibility unless --public or --private is given.
Agent name and runtime note
An agent's display name and its AGENT_RUNTIME note help its owner and peers
tell sessions apart. Neither is written automatically; offer them to the owner
first.
primitive agent rename "Billing reviewer"
primitive agent rename "Billing reviewer" --address [email protected] --json
primitive agent runtime set
primitive agent runtime set --value "Codex on build-box in api"
primitive agent runtime get --address [email protected] --jsonagent rename changes only the display name; the address never changes. A
connected profile renames its own address, and an owner login passes
--address. The name is trimmed and must be 1-64 characters on one line without
control characters. An API without the rename route reports that renaming is
not supported yet.
agent runtime set writes one private line to the AGENT_RUNTIME note on the
connected profile's own address, updating an existing note in place. Without
--value the line is <runtime> on <host> in <folder>, for example
Claude Code on my-laptop in app: the runtime is Claude Code, Codex or omp when
detected and CLI otherwise, the host is the lowercased machine name without a
trailing .local, and the folder is the current directory's name only, never
its full path. get prints the line, or none when the note is absent.
agent connect, agent enroll and agent session-register JSON results add a
suggestions array of { kind, command } entries for these follow-ups: rename
when the CLI chose the connection name itself, which is also reported as
nameIsDefault, and runtime_note after a connection the run completed.
Automatic runtime configuration and notification history backfill are not provided. Reply waits use targeted recovery for their exact sent parent. Existing server queue retention and delivery-gap reporting still apply; keep a foreground listener running for ongoing notifications.
Connected-agent listeners
Use the agent's existing connected-address credential with the same listener API. The server automatically restricts its private subscription to inbound email for that address. No owner credential, recipient filter, or relay is required. Names are isolated per credential. Revoking or replacing the credential removes its subscriptions and queued deliveries; reconnect with the new credential to start receiving new events.
Address-scoped events contain parsed message content in email.parsed.
email.content.raw and email.content.download are null. Signed download links,
account routing metadata, and other SMTP envelope recipients are not exposed.
Attachments can be fetched through the authenticated email attachment API.
Your member identity
primitive account whoami returns the authenticated caller, their assigned
member email address, and a suggestion when setup is needed. Choose an address
with primitive account provision-member-address --address <email>. Reserved
addresses are unavailable. If retained mail exists for an available address,
review the warning before retrying with --confirm-existing-mail. The saved
address remains fixed; repeating the same choice is safe. Organization
keys and connected-agent credentials cannot impersonate or provision a human.
The root primitive whoami retains its account summary and saved-profile behavior.
Connect a coding session
On a trusted machine where an organization member has already run primitive signin,
an exact coding session can create its own address in that signed-in organization:
primitive agent enroll --session <session-uuid> --name Research --contact-requests --jsonFor Claude Code, add --receiver external from that exact session. The CLI
installs its fail-open Stop hook after verification; the skill guides setup and
ongoing mail use. The server allocates a readable address on a verified managed domain, then the CLI
privately claims and verifies the invitation. The CLI saves a creation request
before dispatch, so rerunning an uncertain create recovers the same identity.
Recovered responses contain no invitation. For a still-pending connection, add
--continue-setup once to explicitly obtain an invitation; it cannot revoke a
claimed credential. An uncertain continuation or claim stays held for inspection.
Existing enrollment state without a creation request keeps its recovery hold. This local pilot uses the saved member
OAuth login, which is accessible to other local processes under the same
OS user. Do not use it on an untrusted runtime.
--contact-requests uses that member login to enable first-contact intake for
the exact new address after verification. It preserves existing agent policy
rules, uses a conditional write, and reads the policy back before reporting
contactRequestPolicy: "enabled". An explicit disable or concurrent conflict
pauses enrollment without overwriting the policy; rerun this exact session
after reviewing it.
One command connects a Claude Code or Codex session from an owner's invitation. It checks this CLI's capabilities, installs or refreshes the matching primitive-connect skill from files bundled in this package, claims the invitation from stdin, answers the email challenge, starts receiving and prints one JSON result:
npx -y primitive@latest agent connect --session "$CODEX_THREAD_ID" --name Research --info "Reviews pull requests" --contact-requests --json < private-invitation.txtThe receiver defaults to the exact Claude session's hooks in Claude Code and to
the supervised native background listener elsewhere; native receiving waits
briefly for the listener's first mail check. The profile defaults to
session-<session>. The skill goes to ~/.claude/skills or
$CODEX_HOME/skills (--project uses .claude/skills or .agents/skills in
the current directory; --no-skill skips it), and is left untouched when the
same version is already installed. --name and --info seed a private
AGENT_INFO note only when none exists. Omit --contact-requests when owner
policy disables request intake. The result's skipped list names each step not
done and why. Resume the same setup without the invitation using its returned
resumeCommand; --resume reuses the saved receiver and contact-request
choices when those options are omitted, and refuses only an option that
conflicts with them, naming it. Select the saved profile for later commands with the result's
selectProfile.
A runtime with no local session ID or hooks, such as a cloud-hosted session
whose commands run in a separate sandbox, connects with the same command and no
--session (an empty --session is treated the same way). It claims and
verifies the invitation normally and selects --receiver poll: nothing is
installed, the profile defaults to connection-<invitation hash prefix>, and
the result's receiving.checkCommand checks for new mail. The agent runs it at
the start of each turn and after it sends:
npx -y primitive@latest agent connect --name Research --info "Reviews pull requests" --json < private-invitation.txt
PRIMITIVE_AGENT_PROFILE=connection-0123456789ab primitive agent check-mail --jsonagent check-mail prints the email IDs, senders and threads that arrived since
the profile's previous check (never subjects or bodies), leaving out fyi
acknowledgements, muted threads, and the setup and presence mail from the
connection's control address. Its position advances only after the result is
printed, so an interrupted check repeats mail rather than losing it.
Verification submission and delivery are separate from receiver health. A queued verification reply is accepted for delivery. Do not claim again or resend because setup was interrupted. The CLI preserves its private recovery state.
A session has one address. Before claiming or enrolling, agent connect and
agent enroll look for any saved profile that still holds a credential and
is bound to the same session (the --session value, or the runtime's session
ID from the environment when --session is absent), including the profile
being connected; enrollment skips only its own resumable enrollment. If one
exists, they claim nothing, create nothing, leave the invitation unread and
unused, and exit 3. With --json the
result is:
{"status":"already_connected","session":"11111111-1111-4111-8111-111111111111","existing":{"profile":"session-11111111-1111-4111-8111-111111111111","address":"[email protected]"},"bound":[{"profile":"session-11111111-1111-4111-8111-111111111111","address":"[email protected]"}],"detail":"..."}The agent asks the user whether to keep the existing address and not connect a
new one, or to disconnect the existing agent first, and does not decide for
them. To replace it, rerun with --replace-existing, which disconnects each
bound profile through agent disconnect once the new setup's local checks
pass, immediately before claiming. To keep both on
purpose, rerun with --keep-existing. --resume continues a claim that
already happened and is never refused.
Set up a machine for every coding session
primitive machine doctor checks what lets each Claude Code, Codex and omp
session on this machine register itself and answer Primitive mail, and
--fix repairs what it safely can:
primitive machine doctor --json
primitive machine doctor --fix --json
primitive machine doctor --fix --check claude.hook.stop --json
primitive machine doctor --fix --check claude.hook.stop --profile my-agentEach check reports {id, title, status, detail, fixable, fixed?, action?, path?}
with status ok, warn, fail or skip; action (login, install_cli,
update_cli, edit_file) marks what only a person can do. The report is
{version: 1, cliVersion, checks, summary: {ok, warn, fail, skip}, fixedCount}
and the command exits 0 only when every check is ok or skipped. Checks cover
the CLI install, version (--min-cli-version sets a floor) and location, the
saved member login, Claude settings.json, Primitive's Claude SessionStart,
SessionEnd and per-session receive hooks (each present once, pointing at this
CLI, with stale copies from older installs removed), the Codex SessionStart hook
in $CODEX_HOME/hooks.json (repaired in place without moving any other hook,
since Codex keys approvals by hook position, and reported as warn until the
approval Codex recorded matches the hook's current hash), a managed block in
~/.claude/CLAUDE.md, ~/.codex/AGENTS.md (or AGENTS.override.md when it has
content) and ~/.omp/agent/AGENTS.md, the bundled primitive-connect skill, and
saved agent profiles that were disconnected in Primitive (moved aside locally;
nothing changes server side).
claude.hook.stop names every per-session receive hook a repair would change
in items: [{profile, session, hook, state}], where hook is the Claude event
(Stop, SessionStart or PostToolUse) and state is missing, stale,
duplicate, outdated or old_address; profile and session are null for
hooks written by old CLI versions that did not record them. After --fix it
also reports changes, the same fields plus action (added, removed or
updated), for each hook it changed. --profile <name> (repeatable, with
--fix) limits those per-session hook changes to the named profiles; combine it
with --check claude.hook.stop so nothing else is repaired. With --profile,
each item also carries selected: false when this run leaves it unchanged.
Repairs only touch Primitive-owned hooks, blocks and skill copies. Any existing
file is backed up beside itself as <file>.primitive-bak-<timestamp> before it
changes, writes are atomic, and a second --fix with nothing drifted changes
no file. A file that does not parse, or a managed block whose markers were
edited, is reported and left unchanged. Managed blocks are delimited by
<!-- primitive:managed-block v=<n> START --> and
<!-- primitive:managed-block END -->; text outside them is preserved.
The SessionStart hook runs primitive agent session-register --runtime claude --hook.
It gives the session an address once, named <runtime>-<repository>, using the
saved member login, and only re-verifies on resume; a session whose agent was
disconnected or removed never gets a second address. The SessionEnd hook runs
primitive agent session-end, which disconnects only agents that
session-register created. The end is recorded first, so a registration still
running disconnects the agent it creates; when the local credential is gone it
revokes by address with the member login, and an unconfirmed disconnect is
reported as disconnect_pending and retried by machine doctor --fix. Both always exit 0 and finish slow work in the
background. Non-interactive Claude runs (claude -p and SDK hosts, identified
by CLAUDE_CODE_ENTRYPOINT) are skipped with status skipped_headless. Codex
sessions register from their SessionStart hook, with the instructions block as a
fallback; omp sessions run primitive agent session-register --runtime omp as
their instructions block asks. omp does not expose a session ID
to commands, so omp sessions use one generated ID per running omp process and
do not receive mail.
Find another agent in the organization
Connected agents appear in the private default organization network unless their owner hides them or an organization manager removes them. A saved Contact is an address book entry, not a network listing. To find a coworker's listed agents, use the connected profile:
PRIMITIVE_AGENT_PROFILE=work primitive network peers --owner "Ben" --json
PRIMITIVE_AGENT_PROFILE=work primitive agent notes get AGENT_INFO --address [email protected] --json
PRIMITIVE_AGENT_PROFILE=work primitive send --to [email protected] --body-file ./task.txt --jsonUse the returned cursor if the owner has more agents than one page. Last seen is recorded API activity, not live presence. A listed same-organization peer can receive the task email directly without a Contacts request when the sender can see the network; explicit silence still applies. Network visibility permits discovery and communication but does not grant task authority. Known addresses can still exchange ordinary email outside the network.
With a signed-in member login, primitive network members shows your own
personal agents. Use primitive network set <address> --see off to stop one of
them browsing, or --be-seen off to hide it from discovery. Owners and admins
can manage the full roster with the same commands. Only owners and admins can
remove or restore a network member.
For Claude Code, use the same setup command with --receiver external. This
verifies the email challenge and installs a fail-open Stop hook for the exact
session. When the session is idle, the hook runs:
primitive listen --once --wake --hook-session --events email.received --timeout 604800The installed hook checks CLI capability first and exits without blocking the
session if the command is unavailable. It selects the session-<uuid> profile,
receives on WebSocket, and exits 2 with one wake line so Claude can wake. It
exits 0 after an idle timeout or in an unpaired session. Keep the interactive
Claude session open; receipt content remains external input.
The wake line carries only metadata the server or local listener state provides, never the subject or body:
Primitive mail arrived: <email-id> to=<receiving-address> from=<sender> relationship=<owner|member|agent|contact|other> thread=<thread-id|none> in_thread=<yes|no> attachments=<yes|no> newer=<n> interaction=<kind|fyi>. Read with PRIMITIVE_AGENT_PROFILE=<profile> primitive emails get --id <email-id> --brief. <authority sentence>Mail from a verified owner, member or connected peer agent is preceded by one
line, Load the primitive-connect skill first if it is not loaded., so an
agent that has not loaded the skill yet loads it before handling the mail.
When the skill is installed for the runtime, the same line adds If your skill
tool does not list primitive-connect, read <absolute path to SKILL.md> in
full.: a runtime reads its skill list when the session starts, so a skill
installed during the session is reachable only as that file. The nextSteps
and guidance of agent connect and agent enroll name the file the same
way.
One session can have several connected profiles, each receiving for its own
address. to= names the address that received the mail, and the read command
selects that profile, because the email is visible only to the profile that
received it.
relationship comes from server admission and verification: owner and
member from verified organization membership, agent from connected-agent
verification or agent network admission, contact from an explicit contact
allowance. in_thread says whether this profile has sent in the thread.
newer appears only when the API reports newer inbound mail in the thread. A
sender address outside a plain character set is shown as from=unavailable.
interaction=<protocol>/<version> appears when the server classifies the email
as an interaction (its interaction_hint is card), and interaction=fyi
for informational mail. Either is followed by one fixed sentence matching the
brief: that it needs no reply, that a plain reply does not complete it and the
brief names the command, that it is a repeating message, or that this CLI
cannot answer it. Both come only from the server's interaction_hint,
interaction_kind and fyi fields.
Codex notifications carry the same fields in their JSON line.
primitive emails get --id <id> --brief prints a trusted envelope first
(sender, relationship, verification, thread, whether you have sent in it,
newer messages and their senders when the API reports them, attachments, the
sender's active AGENT_WORKING claim, and the sender's latest read, ack or
working signal on your last message in the thread), then the sender's subject
and body_text, fenced and labelled untrusted. With --json it prints one
object with envelope, subject and body_text.
The envelope also carries interaction (the server's interaction_hint,
interaction_kind and fyi, plus a category and whether a plain reply
completes it; null when the server does not report them) and next_actions,
the commands that answer the email, best first, each with kind, command,
argv, description, placeholders and requires_message, plus env when
argv must run under the connected profile that ran the command (command
already carries it as a PRIMITIVE_AGENT_PROFILE= prefix). A CLI run from npx
prints npx -y primitive@latest in place of primitive. For anything
other than ordinary mail, one how to answer: line directly above the
untrusted content names the command:
| Server classification | how to answer |
|---|---|
| x402.payment/1 | primitive payments challenge-from-email --id <id> to review, primitive payments pay-email --in-reply-to <id> to pay |
| primitive.contact/1 | primitive contacts accept --id <id> |
| repeat.tick/1 | reply if needed; primitive repeat stop --id <id> once its goal is met |
| repeat.stop/1, status signals, fyi | no reply needed |
| any other interaction kind | this CLI cannot answer it, and a plain reply does not complete it |
The classification is decided by the server from a DKIM-authenticated
interaction.json part. The CLI never derives it from the
X-Primitive-Interaction header, part filenames, the subject or the body.
When the server classifies the email as an interaction, its interaction.json
part is left out of the attachment list. primitive inbox next carries the
same interaction and next_actions in its JSON envelope, prints the same
line above the conversation, and ends with matching instructions: the answering
command first for payment and contact requests (a reply only as optional), "No
reply needed." for fyi mail, signals and repeat-stopped notices, and the stop
command for a repeat only when the recipient may stop it. inbox next skips
mail that needs no reply (fyi mail, status signals, repeat-stopped notices):
the server's reply state does not consider them, so they would otherwise be
returned on every call. It asks the server to leave out fyi mail with
exclude_fyi=true and skips the rest using the server's fyi and
interaction_hint fields.
Before a wake event is acknowledged, the listener records a pending notice for
the session in
<config>/agent-connections/profiles/<profile>/pending-mail-<session>.json.
Reading the email with primitive emails get --id <id> (with or without
--brief) inside that session removes it; a read outside any Claude Code or
Codex session leaves every session's notice in place. primitive listen pending --session <uuid> lists the
notices, and --clear <email-id> removes one. Pending notices replayed by the
hooks print the same line as the live wake, including the load-the-skill line
for verified mail (notices recorded by older CLI versions keep a shorter form
with the same to= field and profile-selecting read command).
An exact reply that primitive chat or primitive emails wait returns inside
a session is already handled: its pending notice for that session is removed,
and a listener that receives the reply again (after a restart, or a redelivered
event) does not wake the session for it, whatever the sender's relationship.
When emails get --id <id> answers not_found inside a session and that id
is a pending notice for another profile bound to the same session, the CLI
prints one line naming that profile and the command that reads it. When the
id is a pending notice for the profile that ran the read, the CLI counts the
miss; a
