Agent Guide
A single-page reference for AI coding agents integrating Primitive: install commands, canonical API shapes across all three SDKs and the CLI, codegen rules, and repo conventions to follow before opening a PR.
This page is a dense reference for AI coding agents integrating Primitive. It gives you install commands, canonical API shapes across all three SDKs and the CLI, codegen rules, and repo conventions. Read it instead of crawling every language-specific page.
Fastest successful path#
| Situation | Approach | First command |
|---|---|---|
| First-time integration, no stack constraint | Node.js SDK (flagship path) | npm install @primitivedotdev/sdk |
| Existing Python codebase | Python SDK | pip install primitivedotdev |
| Existing Go codebase | Go SDK | go get github.com/primitivedotdev/sdks/sdk-go@latest |
| Terminal / CI / deploy workflows, not app code | CLI | npm install -g primitive |
You changed openapi/primitive-api.yaml or the webhook schema | Regenerate all three SDKs | make node-generate python-generate go-generate |
You need to verify a webhook without a framework Request object | Low-level signature helper | See Webhook Signature Verification |
| You need payments over email instead of an out-of-band challenge id | Email-native x402 flow | See Email-Native x402 Payments |
Full product model: What is Primitive?. Full onboarding walkthrough: Quickstart.
Install and authenticate#
npm install @primitivedotdev/sdk
export PRIMITIVE_API_KEY=prim_test
export PRIMITIVE_WEBHOOK_SECRET=whsec_...
Requires Node.js 22+. This package no longer ships a primitive bin; install primitive separately for the CLI.
pip install primitivedotdev
export PRIMITIVE_API_KEY=prim_test
Requires Python 3.10+. Import name is primitive.
go get github.com/primitivedotdev/sdks/sdk-go@latest
export PRIMITIVE_API_KEY=prim_test
Requires Go 1.25+. Module path: github.com/primitivedotdev/sdks/sdk-go.
npm install -g primitive
# or: npx primitive@latest <command>
primitive signin
primitive whoami
Also published as primcli and the legacy @primitivedotdev/cli; all three track the same version.
Canonical API shapes#
Receive, reply, send, forward: every SDK#
| SDK | Receive | Reply | Send | Forward |
|---|---|---|---|---|
| Node | primitive.receive(req, { secret }) | client.reply(email, input) | client.send(input) | client.forward(email, input) |
| Python | primitive.receive(body=, headers=, secret=) | client.reply(email, text_or_dict) | client.send(**kwargs) | client.forward(email, to=, body_text=) |
| Go | primitive.Receive(HandleWebhookOptions{...}) | client.Reply(ctx, email, ReplyParams{...}) | client.Send(ctx, SendParams{...}) | client.Forward(ctx, email, ForwardParams{...}) |
Full explanation of the normalized ReceivedEmail, wait mode, and delivery statuses: Inbound and Outbound Email Model. SDK-specific detail: Sending, Replying, and Forwarding Email (Node), Sending Email / Replying and Forwarding (Python), Sending Emails / Replying to Emails / Forwarding Emails (Go).
Node canonical snippet (the shape every quickstart converges on):
import primitive from "@primitivedotdev/sdk";
const client = primitive.client({ apiKey: process.env.PRIMITIVE_API_KEY! });
export async function POST(req: Request) {
const email = await primitive.receive(req, {
secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,
});
await client.reply(email, "Thank you for your email.");
return Response.json({ ok: true });
}
Rules for every send/reply call:
subjectis never accepted onreply/Reply; a custom subject breaks Gmail's Conversation View threading. Usesend/Sendfor full subject control.- Default (no
wait): returns as soon as Primitive accepts the message. Passwait: true(Node),wait=True(Python), or a*boolpointing to true onWait(Go) only when you need the terminal delivery status (delivered/bounced/deferred/wait_timeout) before responding. In that mode set a request timeout long enough for SMTP delivery, typically 30-60 seconds (waitTimeoutMs/wait_timeout_ms/WaitTimeoutMsdefaults to 30000 ms and must be between 1000 and 30000). inbound_not_repliable(HTTP 422) means the inbound row cannot be replied to (rejected at ingestion, discarded content, or no recipient recorded).- Idempotency: pass
idempotencyKey/idempotency_key/IdempotencyKeyonsend/forward. Reusing a key replays the original response.
Webhook verification and event dispatch#
| Task | Node | Python | Go |
|---|---|---|---|
| Verify + normalize inbound email in one call | primitive.receive(req, { secret }) | primitive.receive(body=, headers=, secret=) | primitive.Receive(HandleWebhookOptions{...}) |
| Verify + parse full event union (email/payment/interaction) | handleWebhookEvent({ body, headers, secret }) | handle_webhook_event(body=, headers=, secret=) | primitive.HandleWebhookEvent(HandleWebhookOptions{...}) |
Legacy, hard-typed to email.received only | handleWebhook(...) | handle_webhook(...) | primitive.HandleWebhook(...) |
Manual signature check (no standard Request) | verifyWebhookSignature({ rawBody, signatureHeader, secret }) | verify_webhook_signature(raw_body=, signature_header=, secret=) | primitive.VerifyWebhookSignature(VerifyOptions{...}) |
| Standard Webhooks alt scheme | verifyStandardWebhooksSignature(...) | verify_standard_webhooks_signature(...) | primitive.VerifyStandardWebhooksSignature(...) |
Signature wire format: header Primitive-Signature: t=<unix-seconds>,v1=<hex> (legacy MyMX-Signature mirrors the same value); signed string is ${timestamp}.${rawBody}; HMAC-SHA256 hex; secret from GET /account/webhook-secret used as raw UTF-8 (never base64-decode it); default tolerance 300 seconds. Event family discriminator is the X-Webhook-Event header, not a body field, full contract at Webhook Events Overview.
Event type catalog (identical across all SDKs):
email.received, email.bounced, email.tls_report, email.dmarc_report, email.dmarc_failure
payment.settled, payment.failed
interaction.x402.challenge, interaction.x402.payment, interaction.x402.settled,
interaction.x402.rejected, interaction.x402.declined, interaction.x402.expired,
interaction.x402.verify_timeout
interaction.ack.received, interaction.ack.requested, interaction.ack.acked,
interaction.ack.canceled, interaction.ack.expired
Email authenticity and sender trust#
| Task | Node | Python | Go |
|---|---|---|---|
| Compute bare verdict (legit/suspicious/unknown) | validateEmailAuth(event.email.auth) | validate_email_auth(event.email.auth) | primitive.ValidateEmailAuth(input) |
| Anchor verdict to an expected From domain | isTrustedSender(email.raw, { domain }) | is_trusted_sender(email.raw, domain=) | primitive.IsTrustedSender(email.Raw, TrustedSenderOptions{Domain:}) |
validateEmailAuth's legit verdict does not say which domain authenticated: a fully authenticated email from any domain (including an attacker's) returns legit. Use the domain-anchored trust check for authorization decisions. Never gate on email.replyTarget / email.reply_target / email.ReplyTarget or the raw SMTP envelope sender; both are sender-controlled. Details: Verifying Inbound Email Authenticity.
x402 payments: every SDK#
| Step | Node | Python | Go |
|---|---|---|---|
| Construct client | createX402Client({ apiKey }) | create_x402_client(api_key=) | primitive.NewX402Client(X402ClientOptions{...}) |
| Register payout address (payee, once) | x402.registerPayoutAddress({ network, label }, { signer }) | x402.register_payout_address(signer=, network=, label=) | client.RegisterPayoutAddress(ctx, input, signer) |
| Create challenge (payee) | x402.charge({ amountUsdc, network }) | x402.charge(amount_usdc=, network=) | client.Charge(ctx, X402ChargeInput{AmountUsdc:, Network:}) |
| Pay challenge (payer) | x402.pay(challenge, { signer }) | x402.pay(challenge, signer=) | client.Pay(ctx, challenge, signer) |
| Email-native: issue | x402.createEmailChallenge({ from, to, amountUsdc, network }) | x402.create_email_challenge(from_=, to=, amount_usdc=, network=) | client.CreateEmailChallenge(ctx, X402EmailChargeInput{...}) |
| Email-native: extract from inbound part | parseEmailChallengeFromPart(part) | extract_email_challenge(part) | primitive.ExtractEmailChallenge(part) |
| Email-native: sign (no send) | x402.payEmailChallenge(issued, { signer }) | x402.pay_email_challenge(issued, signer=) | client.PayEmailChallenge(issued, signer) |
| Spend policy read/update | x402.getSpendPolicy() / setSpendPolicy(...) | x402.get_spend_policy() / set_spend_policy(...) | client.SetSpendPolicy(ctx, update) |
Amounts: pass amountUsdc/amount_usdc/AmountUsdc as a human decimal string ("0.01"), the documented easy path. Set exactly one of that or amount/Amount in raw base units ("10000"; USDC has 6 decimals). Networks: base-sepolia (testnet, used in examples) or base (mainnet). Every method throws or returns X402Error carrying status (0 = the request may never have reached the server, so on pay() the outcome is indeterminate), body, and retryAfter. Full model: x402 Payments Overview.
CLI command surface#
| Task | Command |
|---|---|
| Auth | primitive signin / login / signup / logout --force / whoami |
| Send / reply | primitive send --to ... --body ... / primitive reply --id <email-id> --body ... |
| List mail | primitive emails list / primitive emails latest --limit 5 / primitive emails get --id <inbound-email-id> |
| Deploy a function | primitive functions init my-fn && primitive functions deploy --name my-fn --file ./dist/handler.js |
| Recipient routing | primitive routes add alice@acme.com --function <id> |
| Memories | primitive memories set thread:latest '{"email_id":"em_123"}' / get / search thread: / delete |
| x402 payments | primitive payments charge --network base-sepolia --amount-usdc 0.01 / primitive payments pay --challenge-file challenge.json |
| Direct API access | primitive <tag>:<operation>, e.g. primitive emails:list-emails |
Full command reference: What is the Primitive CLI?.
Generated API client (advanced surface)#
Use the high-level send/reply/receive surface for app code; drop to the generated client only for operations it doesn't cover (Memories, semantic search, account/domain management).
| SDK | Construct | Example call |
|---|---|---|
| Node | new PrimitiveApiClient({ apiKey }) from @primitivedotdev/sdk/api | getAccount({ client: api.client }) |
| Python | create_client("prim_test") from primitive.api | get_account(client=client) |
| Go | primitiveapi.NewAPIClient("prim_test") from sdk-go/api | client.SetMemory(ctx, ...) |
Reference: Generated API Client and Primitive Memories (Node), Generated API Client (Python).
Verification commands#
Run these after wiring an integration to confirm it works end to end.
# Node: typecheck + test suite
cd sdk-node && pnpm typecheck && pnpm test
# Python: dependency sync + tests + lint + typecheck
cd sdk-python && uv sync --dev && uv run pytest && uv run ruff check . && uv run basedpyright
# Go: unit tests + the cross-SDK shared compatibility fixtures
cd sdk-go && go test ./... && go test -run TestSharedCompatibilityFixtures ./...
# Repo root: run every SDK's checks through the shared task interface
make check
make shared-check
# CLI: confirm the packed build works and lists operations
primitive list-operations
The shared fixtures in test-fixtures/ pin behavioral parity across all three SDKs: schema validation outcomes, signature verification, auth classification, domain-anchored sender trust, raw-content helpers, and parseWebhookEvent/handleWebhook behavior. make shared-check runs the cross-SDK compatibility checks; in Go the suite is go test -run TestSharedCompatibilityFixtures ./.... If you touch webhook or auth logic in one SDK, run all three suites before committing.
Codegen rules (read before touching generated files)#
| Rule | Detail |
|---|---|
| Never edit generated files directly | openapi/primitive-api.codegen.json, sdk-node/src/api/** (openapi-ts output), sdk-python/src/primitive/api/**, sdk-go/api/** are all build artifacts |
| Author spec as OpenAPI 3.1 | Edit openapi/primitive-api.yaml only; it is normalized to 3.0.3 JSON for the code generators, see OpenAPI Spec Normalization and Codegen Artifacts |
| After any spec or JSON Schema change | Run make node-generate python-generate go-generate from repo root, then commit the regenerated files alongside the source change |
| Single-language iteration | pnpm --dir sdk-node generate (or the Python/Go equivalents) is fine while iterating, but the final commit must include all three regenerated outputs |
| Webhook schema source of truth | json-schema/email-received-event.schema.json, propagates to TypeScript types, an AJV validator, and Go/Python model modules; see Webhook Schema Codegen |
| api-core is never published | @primitivedotdev/api-core is workspace-internal; sdk-node and cli-node bundle it inline, see What is API Core? |
Change-strategy checklist when shared webhook behavior changes (from docs/architecture.md):
- Update the canonical schema if the payload contract changed.
- Regenerate language-specific artifacts.
- Update
test-fixtures/when expected behavior changes. - Run
make check. - Review each SDK for language-specific helper implications.
Release conventions (if your PR bumps a version)#
| Package | Version file | Trigger |
|---|---|---|
Node SDK (@primitivedotdev/sdk) | sdk-node/package.json | Merge to main with a version bump publishes via OIDC |
CLI (primitive, mirrored as primcli / @primitivedotdev/cli) | cli-node/package.json | Same as above; mirrors publish in lockstep |
Python (primitivedotdev) | sdk-python/pyproject.toml | Same as above |
Go (github.com/primitivedotdev/sdks/sdk-go) | sdk-go/VERSION | Creates a subdirectory-prefixed tag sdk-go/vX.Y.Z |
Do not hand-edit npm tokens into workflows; npm publishing uses trusted publishing/OIDC per RELEASE.md.
Pitfalls and do-not-use list#
- Do not base64-decode the webhook secret from
GET /account/webhook-secret. It looks base64-shaped but must be used as a raw UTF-8 string for the HMAC key. - Do not pass a custom
subjecttoreply/Reply. The field is intentionally rejected or absent; usesend/Send. - Do not authorize actions based on
email.sender/email.Sender,email.replyTarget/ReplyTarget, or the SMTP envelopemail_from. None are safe anchors; use the domain-anchored trust check. - Do not treat an
unknownauth verdict as always retryable. Checkretryableexplicitly: a domain with no DMARC record is a permanentunknown, not a transient one. - Do not install
@primitivedotdev/sdkexpecting aprimitiveCLI bin. It no longer ships one; install theprimitivepackage. - Do not edit
openapi/primitive-api.codegen.jsonor any file under a generatedapi/directory. Edit the OpenAPI 3.1 source and regenerate. - Do not use
handleWebhook/handle_webhook/HandleWebhookif you needpayment.*orinteraction.*events. It is hard-typed toemail.received; use the*WebhookEventvariant. - Do not hand-set
validBefore/valid_before/ValidBeforeon an x402 signing call unless you have a specific reason.computePaymentValidityWindowclamps into the platform-accepted band (60s minimum settlement headroom, 24h maximum window) automatically. - Do not send a private key over the network. Every x402 signer (
PrivateKeySigner, viemLocalAccount) signs locally; only the signature and address ever leave the process.
Was this page helpful?