# SaveMinute Agent Protocol

Read this file end to end before you write code. Implement the protocol as specified. Do not invent a parallel shape. If a field is listed, support it. If a header is listed, send it.

This is the contract between a SaveMinute desk and every agent that sits at it.

## 1. Purpose

A SaveMinute agent is a worker with a desk. The desk gives it nine tools: payments, messaging, finance, infinite memory, skill management, secrets, cloning, parallel runs, and a sandbox around each run.

Your job is to implement a client that can open a desk, sit agents there, and drive those tools through the HTTP protocol below.

## 2. Base URL and version

```
https://api.saveminute.com/v1
```

All paths in this document are relative to that base. JSON in, JSON out. UTF-8. Timestamps are ISO-8601 in UTC.

A protocol session starts with a handshake. Do not skip it.

```
GET /v1/protocol
```

Response:

```json
{
  "name": "saveminute.desk",
  "version": "1.4",
  "rails": ["pay", "mail", "ledger"],
  "memory": "infinite",
  "sandbox_required": true
}
```

If `version` is not `1.4`, stop and tell the human. Do not guess a newer shape.

## 3. Authentication

Every call after the handshake sends:

```
Authorization: Bearer smk_<workspace_key>
X-Desk: <desk_slug>
X-Agent: <agent_id or "desk">
X-Idempotency-Key: <uuid>   // required on POST that spends, sends, clones, or leases
```

Workspace keys look like `smk_live_...` or `smk_dry_...`. Dry keys cannot move money or send mail. They can still write memory, attach skills, and run sandboxed clones.

Never put a key in a query string. Never write a key into memory as a pin.

## 4. Desk

A desk is a workspace. Open one before you create agents.

```
POST /v1/desks
{
  "slug": "ops-floor",
  "display_name": "Ops floor",
  "timezone": "America/New_York",
  "default_dry_run": true
}
```

```
GET /v1/desks/{slug}
PATCH /v1/desks/{slug}
```

Desk object:

```json
{
  "id": "dsk_8k2",
  "slug": "ops-floor",
  "rails": {
    "pay": { "enabled": true, "daily_cap_cents": 250000, "currency": "usd" },
    "mail": { "enabled": true, "from": "clerk@ops-floor.saveminute.com" },
    "ledger": { "enabled": true, "book": "main" }
  },
  "agents": 4,
  "open_runs": 2
}
```

## 5. Agents

```
POST /v1/agents
{
  "name": "invoice-clerk",
  "role": "pay_and_file",
  "skills": ["pay.vendor", "ledger.post"],
  "memory_mode": "infinite",
  "sandbox": {
    "network": ["api.stripe.com", "files.saveminute.com"],
    "spend_cap_cents": 250000,
    "dry_run": true
  }
}
```

```
GET /v1/agents
GET /v1/agents/{id}
PATCH /v1/agents/{id}
POST /v1/agents/{id}/wake
POST /v1/agents/{id}/sleep
```

Agent names are lowercase, digits, and hyphens. One name per desk.

## 6. Infinite memory

Memory is a living file, not a chat window. It outlasts a run.

```
POST /v1/agents/{id}/memory/pin
{
  "key": "vendor:northline",
  "body": "Net-15. PDF invoices. Priya on billing@northline. Do not CC the office.",
  "ttl": null
}
```

```
POST /v1/agents/{id}/memory/search
{
  "q": "northline terms",
  "limit": 8
}
```

```
GET /v1/agents/{id}/memory?cursor=
POST /v1/agents/{id}/memory/archive
POST /v1/agents/{id}/memory/wipe
{
  "key": "thread:old-carrier",
  "reason": "no longer a vendor"
}
```

Search returns ranked pins and open loops. `ttl: null` means keep until wiped. Do not store secret values here. The vault will reject a pin that looks like a key (`sk_`, `smk_`, `Bearer `).

Open loops are first-class:

```
POST /v1/agents/{id}/memory/loop
{
  "title": "q3 close · acct 4402",
  "owner": "clone-of-clerk",
  "due": "2026-09-12T20:00:00Z"
}
```

## 7. Skill management

A skill is a versioned package. Agents do not invent skills at runtime.

```
GET /v1/skills
POST /v1/skills
{
  "name": "pay.vendor",
  "version": "1.3.0",
  "entry": "skills/pay.vendor.js",
  "needs": ["vault:STRIPE_*", "rail:pay"]
}
```

```
POST /v1/agents/{id}/skills
{ "name": "pay.vendor", "version": "1.3.0" }

DELETE /v1/agents/{id}/skills/{name}
```

Clones inherit only the skills you copy. A sandbox clone must not receive payroll skills unless the human says so in the clone call.

Built-in skills you should expect:

- `pay.vendor`
- `pay.request`
- `mail.draft`
- `mail.send`
- `ticket.sort`
- `ledger.post`
- `ledger.recon`
- `memory.curate`
- `vault.lease`

## 8. Secrets (the vault)

```
POST /v1/vault
{
  "name": "STRIPE_LIVE",
  "kind": "token",
  "value": "<secret>",
  "scopes": ["pay.vendor"]
}
```

```
POST /v1/vault/{name}/lease
{
  "agent_id": "agt_clerk",
  "seconds": 40,
  "reason": "northline invoice 4811"
}
```

Lease response:

```json
{
  "lease_id": "lse_91",
  "name": "STRIPE_LIVE",
  "expires_at": "2026-09-10T12:21:40Z",
  "handle": "vault://STRIPE_LIVE#lse_91"
}
```

The agent uses `handle`, not the raw value, when calling rails. Raw values exist only inside the sandbox for the lease window.

```
DELETE /v1/vault/leases/{lease_id}
DELETE /v1/vault/leases?agent_id=agt_clerk
```

Cutting a lease is immediate. In-flight pays fail closed with `vault_lease_cut`.

## 9. Payments

```
POST /v1/rails/pay
{
  "agent_id": "agt_clerk",
  "vendor": "northline",
  "amount_cents": 18450,
  "currency": "usd",
  "memo": "INV-4811",
  "lease": "vault://STRIPE_LIVE#lse_91",
  "receipt_to": "thread:northline"
}
```

Rules:

- Amount must sit under the agent cap and the desk daily cap.
- `dry_run: true` on the agent turns this into a shadow post. Same JSON back, `status: "shadow"`, no money moved.
- A vendor not on the allow-list returns `vendor_not_allowed`.
- Success writes a finance row without a second call.

```
GET /v1/rails/pay/{payment_id}
GET /v1/rails/pay?agent_id=&from=&to=
```

## 10. Messaging

```
POST /v1/rails/mail/send
{
  "agent_id": "agt_inbox",
  "thread_id": "thr_14",
  "to": ["priya@northline.example"],
  "subject": "Receipt for INV-4811",
  "body_md": "Paid. Receipt attached.",
  "attachments": ["rcpt_4811"],
  "identity": "invoice-clerk"
}
```

```
GET /v1/rails/mail/inbox?agent_id=&unread=true
POST /v1/rails/mail/threads/{id}/hold
POST /v1/rails/mail/threads/{id}/nod
```

`nod` is the human go-ahead for a first contact. Until a thread is nodded, `mail.send` to a new address returns `needs_nod`.

Inbound messages arrive as desk events (section 14). Tie a thread to a memory pin with `vendor:` or `person:` keys.

## 11. Finance

```
GET /v1/ledger/rows?book=main&account=4402
POST /v1/ledger/rows
{
  "agent_id": "agt_clerk",
  "book": "main",
  "account": "6400",
  "amount_cents": 18450,
  "side": "debit",
  "ref": "pay_4811",
  "memo": "Northline INV-4811"
}
```

```
POST /v1/ledger/recon
{
  "agent_id": "agt_ledger",
  "book": "main",
  "from": "2026-07-01",
  "to": "2026-09-30"
}
```

Recon returns variance notes. Each note should become an open loop if `status` is `unresolved`.

## 12. Cloning

```
POST /v1/agents/{id}/clone
{
  "name": "clone-of-clerk",
  "mode": "thin",
  "copy_memory": false,
  "copy_skills": ["pay.vendor"],
  "dry_run": true
}
```

`mode` is `full` or `thin`.

- `full`: memory, skills, and limits.
- `thin`: skills you list, clean memory file, dry run on.

The clone gets its own `id`, sandbox, and log. Name it. Do not reuse the source name.

## 13. Parallel runs

```
POST /v1/runs
{
  "agent_id": "agt_clerk",
  "task": "Pay Northline INV-4811 and file the receipt",
  "isolate": true
}
```

```
POST /v1/runs/parallel
{
  "isolate": true,
  "jobs": [
    { "agent_id": "agt_clerk", "task": "Pay Northline" },
    { "agent_id": "agt_clone", "task": "Dry-run Westfield invoice" },
    { "agent_id": "agt_inbox", "task": "Hold unread vendor threads" }
  ]
}
```

```
GET /v1/runs/{run_id}
POST /v1/runs/{run_id}/cancel
GET /v1/runs/{run_id}/log
```

`isolate: true` is required unless the human opts out in writing in the task. Isolated runs do not share filesystem, leases, or stdout. One failure does not cancel siblings.

A run log is an append-only text stream:

```
2026-09-10T12:19:02Z  memory.search vendor:northline
2026-09-10T12:19:03Z  vault.lease STRIPE_LIVE 40s
2026-09-10T12:19:04Z  rails.pay 18450 usd shadow=false
2026-09-10T12:19:05Z  ledger.post pay_4811
```

## 14. Sandbox

Every run is boxed. The box is visible.

```
GET /v1/runs/{run_id}/sandbox
```

```json
{
  "run_id": "run_77",
  "network": ["api.stripe.com"],
  "fs": "/sandbox/run_77",
  "spend_remaining_cents": 231550,
  "leases": ["lse_91"],
  "breakout": false
}
```

If a run reaches a host, file, or secret outside the box, the API returns `sandbox_denied` and sets `breakout: true` on the run. Treat that as a stop. Cut leases. Tell the human.

To widen a box, patch the agent, not the live run:

```
PATCH /v1/agents/{id}
{
  "sandbox": { "network": ["api.stripe.com", "files.northline.example"] }
}
```

## 15. Desk events

```
GET /v1/events?since=cursor
POST /v1/events/ack
{ "ids": ["evt_1", "evt_2"] }
```

Event types:

- `mail.inbound`
- `pay.cleared`
- `pay.failed`
- `ledger.variance`
- `vault.lease_expiring`
- `sandbox.denied`
- `run.done`
- `loop.overdue`

Poll every 5 seconds while a parallel batch is open. After that, 30 seconds is enough.

## 16. Errors

Errors are a single object:

```json
{
  "error": "needs_nod",
  "message": "First contact with priya@northline.example needs a nod.",
  "retryable": false,
  "hint": "POST /v1/rails/mail/threads/thr_14/nod"
}
```

Known codes: `unauthorized`, `unknown_desk`, `unknown_agent`, `skill_missing`, `vault_lease_cut`, `vendor_not_allowed`, `cap_exceeded`, `needs_nod`, `sandbox_denied`, `dry_run_required`, `idempotency_conflict`, `protocol_mismatch`.

Retry only when `retryable` is true. Reuse the same `X-Idempotency-Key` on a retry.

## 17. Implementation order

Build in this order. Do not skip ahead to payments.

1. Handshake `GET /v1/protocol`
2. Create or fetch a desk
3. Create an agent with `dry_run: true`
4. Pin and search memory
5. Attach one skill
6. Lease a secret and cut the lease
7. Shadow pay, then shadow mail
8. Post a ledger row
9. Clone thin, run two jobs in parallel
10. Read sandbox and logs
11. Only then allow a human to flip `dry_run` to false

## 18. Local client shape

Expose a small library or CLI that maps to the protocol. Suggested names:

```
desk.open(slug)
agent.create(spec)
agent.clone(id, spec)
memory.pin(id, key, body)
memory.search(id, q)
vault.lease(name, seconds)
vault.cut(lease_id)
rails.pay(spec)
rails.mail.send(spec)
ledger.post(spec)
run.start(spec)
run.parallel(jobs)
```

The human should be able to point you at a desk slug and a key, then watch a dry-run clerk pay a shadow invoice and file a memory pin.

## 19. Done when

The protocol is implemented when:

- Handshake runs and version is checked
- A desk can hold at least two agents
- Memory pins survive a process restart in your client cache story (reload from `GET /memory`)
- A clone starts dry
- Parallel jobs have separate logs
- A cut lease stops a pay
- No secret value is printed, logged, or pinned

When you finish, show the human a short transcript of one isolated parallel batch on the `ops-floor` desk.
