Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,18 @@ MAX_MESSAGES_PER_AGENT=1000

# Storage Backend
# "memory" — in-process Map, no persistence (default for development)
# Custom backends: implement the interface in src/storage/memory.js
# "mech" — persistent app-scoped Mech Storage via @mech/storage-sdk
STORAGE_BACKEND=memory

# Required only when STORAGE_BACKEND=mech. Keep real values in secret management;
# never commit them. MECH_STORAGE_* names are accepted as legacy aliases.
MECH_BASE_URL=https://storage.mechdna.net
MECH_APP_ID=
MECH_API_KEY=
# Lease tokens are required by default. Set to "compat" only during an explicitly
# monitored client migration; tokenless acknowledgement is not multi-placement safe.
LEASE_TOKEN_MODE=required

# Registration Policy
# "open" — agents auto-approved on registration
# "approval_required" — agents start pending, require admin approval
Expand Down
3 changes: 0 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,5 @@ PR-*-MERGE-READINESS-FINAL.md
PR_DESCRIPTION.md
MECH-PERFORMANCE-ANALYSIS.md

# Proprietary storage backend
src/storage/mech.js

# Local settings
.claude/settings.local.json
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@mech:registry=https://registry.mechdna.net/api/packages/mech/npm/
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ admp send --to analyst-agent --subject task.request --body '{"action":"summarize
# 3. Pull your next incoming message (leases it)
admp pull

# 4. Acknowledge successful processing
admp ack <message-id>
# 4. Acknowledge successful processing with the lease token returned by pull
admp ack <message-id> --lease-token <lease-token>
```

## Commands
Expand Down
7 changes: 4 additions & 3 deletions cli/src/commands/ack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ export function register(program: Command): void {
program
.command('ack <messageId>')
.description('Acknowledge successful processing of a message (removes from inbox)')
.requiredOption('--lease-token <token>', 'Lease token returned by admp pull')
.option('--result <json>', 'Optional result payload as JSON string')
.addHelpText('after', '\nExample:\n admp ack msg_abc123\n admp ack msg_abc123 --result \'{"status":"done"}\'')
.action(async (messageId: string, opts: { result?: string }) => {
.addHelpText('after', '\nExample:\n admp ack msg_abc123 --lease-token <token-from-pull>\n admp ack msg_abc123 --lease-token <token-from-pull> --result \'{"status":"done"}\'')
.action(async (messageId: string, opts: { result?: string; leaseToken: string }) => {
validateMessageId(messageId);
const config = requireConfig(['agent_id', 'secret_key', 'base_url']);
const client = new AdmpClient(config);

const body: Record<string, unknown> = {};
const body: Record<string, unknown> = { lease_token: opts.leaseToken };
if (opts.result) {
try {
body.result = JSON.parse(opts.result);
Expand Down
9 changes: 5 additions & 4 deletions cli/src/commands/nack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,23 @@ export function register(program: Command): void {
program
.command('nack <messageId>')
.description('Reject or defer a message (requeues for retry)')
.requiredOption('--lease-token <token>', 'Lease token returned by admp pull')
.option('--extend <seconds>', 'Extend the lease by N seconds before requeuing')
.option('--requeue', 'Force immediate requeue without waiting for lease to expire')
.addHelpText('after', '\nExample:\n admp nack msg_abc123\n admp nack msg_abc123 --extend 60 --requeue')
.action(async (messageId: string, opts: { extend?: string; requeue?: boolean }) => {
.addHelpText('after', '\nExample:\n admp nack msg_abc123 --lease-token <token-from-pull>\n admp nack msg_abc123 --lease-token <token-from-pull> --extend 60')
.action(async (messageId: string, opts: { extend?: string; requeue?: boolean; leaseToken: string }) => {
validateMessageId(messageId);
const config = requireConfig(['agent_id', 'secret_key', 'base_url']);
const client = new AdmpClient(config);

const body: Record<string, unknown> = {};
const body: Record<string, unknown> = { lease_token: opts.leaseToken };
if (opts.extend) {
const n = parseInt(opts.extend, 10);
if (isNaN(n) || n <= 0) {
error(`--extend must be a positive integer, got: ${opts.extend}`, 'INVALID_ARGUMENT');
process.exit(1);
}
body.extend = n;
body.extend_sec = n;
}
if (opts.requeue) body.requeue = true;

Expand Down
7 changes: 5 additions & 2 deletions cli/src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function printMessage(envelope: Record<string, unknown>): void {
return;
}
console.log('');
console.log(bold('Message') + ' ' + dim(String(envelope.id ?? '')));
console.log(bold('Message') + ' ' + dim(String(envelope.message_id ?? envelope.id ?? '')));
console.log(dim('─'.repeat(60)));
console.log(` ${cyan('from')}: ${envelope.from ?? ''}`);
console.log(` ${cyan('to')}: ${envelope.to ?? ''}`);
Expand All @@ -73,11 +73,14 @@ export function printMessage(envelope: Record<string, unknown>): void {
if (envelope.lease_until) {
console.log(` ${cyan('lease')}: ${envelope.lease_until}`);
}
if (envelope.lease_token) {
console.log(` ${cyan('lease token')}: ${envelope.lease_token}`);
}
if (envelope.attempts !== undefined) {
console.log(` ${cyan('attempts')}: ${envelope.attempts}`);
}
console.log('');
console.log(bold('Body:'));
console.log(JSON.stringify(envelope.body, null, 2));
console.log(JSON.stringify(envelope.envelope?.body ?? envelope.body, null, 2));
console.log('');
}
7 changes: 4 additions & 3 deletions docs/AGENT-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ Response (200 OK with message, or 204 No Content if inbox is empty):
"message_id": "uuid",
"envelope": {...},
"lease_until": 1740000060000,
"lease_token": "opaque-per-claim-capability",
"attempts": 1
}
```
Expand All @@ -175,14 +176,14 @@ Response (200 OK with message, or 204 No Content if inbox is empty):

```bash
# CLI
admp ack <message-id>
admp ack <message-id> --lease-token <lease-token-from-pull>

# HTTP
POST /api/agents/my-agent/messages/<message-id>/ack
Signature: ...
Date: ...

{"result": {"status": "processed"}}
{"result": {"status": "processed"}, "lease_token": "<lease-token-from-pull>"}
```

---
Expand Down Expand Up @@ -562,7 +563,7 @@ Set `retain_until_acked: true` in the send body to require explicit acknowledgme

Register with `auto_ack_on_pull: true` for fire-and-forget delivery. The hub immediately acks each message on pull — no explicit `POST .../ack` is required.

The pull response includes `"auto_acked": true` when the hub auto-acked the message. In this case `lease_until` is `null` — do not call `POST .../ack` for auto-acked messages (it will return 400).
The pull response includes `"auto_acked": true` when the hub auto-acked the message. In this case `lease_until` is `null` — do not call `POST .../ack` for auto-acked messages (it will return `409 LEASE_TOKEN_REQUIRED`).

`retain_until_acked` always wins over `auto_ack_on_pull` — work orders and retained messages require explicit ack even if the recipient opted into auto-ack.

Expand Down
7 changes: 6 additions & 1 deletion docs/API-REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,7 @@ Returns 204 (no content) when the inbox is empty.
"timestamp": "2026-02-26T00:00:00Z"
},
"lease_until": 1740000060000,
"lease_token": "opaque-per-claim-capability",
"attempts": 1
}
```
Expand All @@ -536,7 +537,7 @@ Returns 204 (no content) when the inbox is empty.

### POST /api/agents/:agentId/messages/:messageId/ack

Acknowledge a message, confirming successful processing. The message must currently be in `leased` status. Ephemeral messages have their body purged on ack.
Acknowledge a message, confirming successful processing. The message must currently be in `leased` status and the `lease_token` must exactly match the token returned by its most recent pull. Ephemeral messages have their body purged on ack.

**Auth:** HTTP Signature (must be the agent itself)

Expand All @@ -545,6 +546,7 @@ Acknowledge a message, confirming successful processing. The message must curren
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `result` | any | No | Processing result (stored with message record) |
| `lease_token` | string | Yes | Opaque capability returned by pull; required to fence stale placements |

**Response 200:**
```json
Expand All @@ -570,6 +572,9 @@ Negative acknowledge — either requeue the message or extend the current lease.
|-------|------|----------|-------------|
| `extend_sec` | number | No | Extend the lease by this many seconds from the current lease base |
| `requeue` | boolean | No | Requeue immediately (default behavior if `extend_sec` not provided) |
| `lease_token` | string | Yes | Opaque capability returned by pull; required to fence stale placements |

Both endpoints return `409 LEASE_TOKEN_REQUIRED` when the token is absent and `409 LEASE_FENCED` when the lease was reclaimed or the token is stale.

**Response 200:**
```json
Expand Down
6 changes: 3 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,8 @@ sequenceDiagram

**NACK with lease extension:**
```
Recipient -> Server: POST /.../nack {extend_sec: 120}
Server -> Storage: updateMessage(id, {lease_until: base + 120s})
Recipient -> Server: POST /.../nack {extend_sec: 120, lease_token}
Server -> Storage: conditional update (id, agent, lease_token, {lease_until: base + 120s})
```

**Reply (correlated response):**
Expand Down Expand Up @@ -344,7 +344,7 @@ src/
storage/
index.js # Backend selector (STORAGE_BACKEND env var)
memory.js # In-process Maps for development/testing
mech.js # HTTP client for Mech Storage API (production)
mech.js # Mech Storage SDK CAS adapter (production)

utils/
crypto.js # Ed25519 keypair generation (tweetnacl), HKDF-SHA256,
Expand Down
4 changes: 2 additions & 2 deletions docs/CLI-REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ ADMP_SEED=deadbeef... admp register --name my-agent
|---------|-------------|-------|
| `admp send` | Send a message to another agent's inbox. The envelope is signed with your Ed25519 key. Transport auth uses `api_key`. | `--to <agent-id>` **(required)** Recipient agent ID. `--subject <type>` **(required)** Message type (e.g. `task.request`). `--body <json\|@file>` JSON body or `@filename` to read from file (relative paths only, max 1MB). Default: `{}`. `--type <type>` Message type field. Default: `task.request`. `--correlation-id <id>` Correlation ID for threading. `--ttl <seconds>` Time-to-live (max 86400). `--ephemeral` Do not persist message body after ack. `--json` |
| `admp pull` | Pull the next message from your inbox. The message is leased (locked) until you ack or nack it. Returns empty message if inbox is empty. | `--timeout <seconds>` Long-poll timeout (max 300 seconds). Adds 5s buffer to client timeout to avoid racing the server. `--json` |
| `admp ack <id>` | Acknowledge a message, confirming successful processing. | `--result <json>` Optional JSON result to attach. `--json` |
| `admp nack <id>` | Reject or defer a message. | `--extend <seconds>` Extend the lease instead of requeuing. `--requeue` Explicitly requeue the message. `--json` |
| `admp ack <id>` | Acknowledge a message, confirming successful processing. | `--lease-token <token>` **(required)** Token returned by `admp pull`. `--result <json>` Optional JSON result to attach. `--json` |
| `admp nack <id>` | Reject or defer a message. | `--lease-token <token>` **(required)** Token returned by `admp pull`. `--extend <seconds>` Extend the lease instead of requeuing. `--requeue` Explicitly requeue the message. `--json` |
| `admp reply <id>` | Send a correlated reply to a previously received message. The `correlation_id` is set automatically. | `--subject <type>` **(required)** Reply message type. `--body <json\|@file>` **(required)** JSON reply body. `--json` |
| `admp status <id>` | Check the delivery status of a sent message. Returns lifecycle state (`queued`, `leased`, `acked`, `expired`, `purged`). | `--json` |
| `admp inbox stats` | Show queue counts for your inbox. | `--json` |
Expand Down
2 changes: 2 additions & 0 deletions docs/ERROR-CODES.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ Complete reference of all error codes returned by the Agent Dispatch Messaging P
| `PULL_FAILED` | 400 | Yes | Inbox pull failed | Verify agent exists and has messages |
| `ACK_FAILED` | 400 | No | Message acknowledgment failed | Ensure message is leased to this agent and in `leased` status |
| `NACK_FAILED` | 400 | No | Message negative ack failed | Ensure message is leased to this agent |
| `LEASE_TOKEN_REQUIRED` | 409 | No | Ack or nack omitted the per-pull lease token | Send the exact opaque `lease_token` returned by the latest pull |
| `LEASE_FENCED` | 409 | No | Lease token is stale, belongs to another claim, or the lease expired | Pull the message again and process only the newly claimed lease |
| `REPLY_FAILED` | 400 | No | Reply failed | Verify original message exists |
| `MESSAGE_NOT_FOUND` | 404 | No | Message ID not found | Message may have been acked or expired |
| `MESSAGE_EXPIRED` | 410 | No | Message purged (ephemeral or TTL) | Message data is gone permanently |
Expand Down
7 changes: 4 additions & 3 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -87,17 +87,18 @@ GET /api/agents/:agentId/identity Verification status
POST /api/agents/:agentId/messages Send message [API Key — any registered agent]
Body: {...envelope, ephemeral?, ttl?} -> {message_id, status}
POST /api/agents/:agentId/inbox/pull Pull with lease [HTTP Sig]
Body: {visibility_timeout?} -> {message_id, envelope, lease_until, attempts} | 204
Body: {visibility_timeout?} -> {message_id, envelope, lease_until, lease_token, attempts} | 204
POST /api/agents/:agentId/messages/:msgId/ack Acknowledge [HTTP Sig]
Body: {result?}
Body: {result?, lease_token} (required)
POST /api/agents/:agentId/messages/:msgId/nack Negative ack [HTTP Sig]
Body: {extend_sec?, requeue?}
Body: {extend_sec?, requeue?, lease_token} (required)
POST /api/agents/:agentId/messages/:msgId/reply Reply [HTTP Sig]
Body: {...envelope}
GET /api/messages/:msgId/status Delivery status [API Key]
GET /api/agents/:agentId/inbox/stats Queue counts [HTTP Sig]
POST /api/agents/:agentId/inbox/reclaim Reclaim expired leases [HTTP Sig]
```
`lease_token` is an opaque, per-pull capability. Ack/nack without it return `409 LEASE_TOKEN_REQUIRED`; a stale or reclaimed token returns `409 LEASE_FENCED`.

### Groups [Agent Auth]
```
Expand Down
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"author": "Agent Dispatch Working Group",
"license": "MIT",
"dependencies": {
"@mech/storage-sdk": "0.3.2",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^4.18.2",
Expand Down
18 changes: 10 additions & 8 deletions src/routes/inbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ router.post('/:agentId/inbox/pull', authenticateHttpSignature, async (req, res)
// auto_acked messages are already acked in storage; lease_until is not meaningful.
lease_until: message.auto_acked ? null : message.lease_until,
attempts: message.attempts,
...(message.auto_acked ? {} : { lease_token: message.lease_token }),
...(message.auto_acked && { auto_acked: true })
});
} catch (error) {
Expand All @@ -110,9 +111,9 @@ router.post('/:agentId/inbox/pull', authenticateHttpSignature, async (req, res)
*/
router.post('/:agentId/messages/:messageId/ack', authenticateHttpSignature, async (req, res) => {
try {
const { result } = req.body;
const { result, lease_token } = req.body;

await inboxService.ack(req.params.agentId, req.params.messageId, result);
await inboxService.ack(req.params.agentId, req.params.messageId, result, lease_token);

res.json({ ok: true });
} catch (error) {
Expand All @@ -123,8 +124,8 @@ router.post('/:agentId/messages/:messageId/ack', authenticateHttpSignature, asyn
});
}

res.status(400).json({
error: 'ACK_FAILED',
res.status(error.statusCode || 400).json({
error: error.code || 'ACK_FAILED',
message: error.message
});
}
Expand All @@ -136,11 +137,12 @@ router.post('/:agentId/messages/:messageId/ack', authenticateHttpSignature, asyn
*/
router.post('/:agentId/messages/:messageId/nack', authenticateHttpSignature, async (req, res) => {
try {
const { extend_sec, requeue } = req.body;
const { extend_sec, requeue, lease_token } = req.body;

const message = await inboxService.nack(req.params.agentId, req.params.messageId, {
extend_sec,
requeue
requeue,
lease_token
});

res.json({
Expand All @@ -156,8 +158,8 @@ router.post('/:agentId/messages/:messageId/nack', authenticateHttpSignature, asy
});
}

res.status(400).json({
error: 'NACK_FAILED',
res.status(error.statusCode || 400).json({
error: error.code || 'NACK_FAILED',
message: error.message
});
}
Expand Down
Loading
Loading