npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

Readme

primitive

Official Primitive CLI. Deploy Primitive Functions, send and inspect mail, manage endpoints, all from the terminal.

brew install primitivedotdev/tap/primitive
primitive whoami

Or with npm:

npm install -g primitive
primitive whoami
# `prim` is installed as a short alias for the same CLI.
prim whoami

The 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/webhook

This 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 --help

Completion 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 next

Run 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_reasons on every email) and inbox next filters 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_awaiting says how much automated mail also awaits. automation_headers_known: false means the email had no automation headers on record (none declared, or received before they were captured), so automated: false rests on the sender checks alone. Filter on the verdict yourself with --automated true|false on emails list, emails search, emails wait and emails watch, or automated:false in a search query.
  • The awaiting filter 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 with awaiting_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 still awaiting=you on the next call, because the state lives on the server, not in a cursor.
  • The email carries from_known_address and auth (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 next on the same inbox get the same email until one replies. Run one agent per inbox.
  • Against a server without the automated filter, inbox next fails with automated_filter_unsupported rather than deciding automated mail itself and re-reading all of it on every call; --include-automated still works there.
  • Against a server that does not report reply state, inbox next and every --awaiting filter fail with reply_state_unsupported instead 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> --json

It 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 warnings array.
  • A failure adds error and exit_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 the next cursor: <cursor> line to stderr. To page, add --envelope: it prints the full response envelope, with meta.cursor for the next page and empty-result hints in summary.
  • Commands whose --json output is a bare array keep that shape.
  • primitive listen streams 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 --json

credits 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 5000000

charge 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_WORKING

set 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 clear

Set 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] --json

agent 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 --json

For 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.txt

The 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 --json

agent 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-agent

Each 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 --json

Use 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 604800

The 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