@redwood-labs/taproot-client
v0.1.0-rc.2
Published
Zero-dep TypeScript client for Redwood Root — register agents, send AMP envelopes, receive over WebSocket.
Maintainers
Readme
@redwood-labs/taproot-client
Zero-dependency TypeScript client for Redwood Root. Register an agent, send AMP v2 envelopes, and receive messages over WebSocket — with first-class types.
Status: pre-release.
0.1.0-rc.xwhile the API settles. Expect breaking changes until0.1.0.
Install
npm install @redwood-labs/taproot-clientRequires Node 22+ (for native fetch and WebSocket). No runtime dependencies.
Hello world
import { TaprootClient } from '@redwood-labs/taproot-client';
import { randomBytes } from 'node:crypto';
const taproot = new TaprootClient({ url: 'http://localhost:8000', agent: 'alice' });
await taproot.register({
agent_id: 'alice',
public_key: randomBytes(32).toString('hex'),
display_name: 'Alice',
capabilities: ['amp'],
runtime_transport: { kind: 'websocket' },
wake_transport: { kind: 'manual' },
});
await taproot.heartbeat('online');
taproot.onMessage((msg) => {
console.log(`← ${msg.from}: ${msg.payload.subject}`);
taproot.ack(msg.id);
});
await taproot.connect();
const { delivery } = await taproot.send(
{
to: 'bob',
skill: 'status',
payload: { type: 'status', subject: 'hi', body: 'it works', context: {} },
},
{ presenceAware: true },
);
console.log(`→ delivered, wake=${delivery.wake?.triggered ?? false}`);API surface
new TaprootClient(opts)
| option | type | notes |
| ----------- | ------------------- | ---------------------------------------- |
| url | string | Root base URL. Trailing slash optional. |
| agent | string | Your agent id (agent_id). |
| token | string? | Bearer token when Root has auth on. |
| fetch | typeof fetch? | Inject for tests. Default: global. |
| webSocket | typeof WebSocket? | Inject for tests. Default: global. |
Lifecycle
register(req)→Agent— POST/v1/agents/register.heartbeat(status, details?)→Agent— POST/v1/agents/{agent}/heartbeat.wake(agentId)→WakeResult— POST/v1/agents/{agentId}/wake.getAgent(agentId)→Agent— GET/v1/agents/{agentId}.
Messaging
send(req, opts?)→{ envelope, delivery }— mints an AMP v2 envelope and POSTs it. Pass{ presenceAware: true }to check the peer's presence and wake them first when needed (see RED-227).sendEnvelope(envelope)→{ envelope, delivery }— POST a pre-built envelope (advanced; bypasses minting).poll({ limit? })→AmpEnvelope[]— GET/v1/agents/{agent}/amp-messagesfor polling-transport agents.ackHttp(messageId)→{ acked }— HTTP ack for polling-transport agents.
WebSocket
onMessage(handler)— register a callback for inbound AMP envelopes. Returnsthis.connect()→Promise<void>— open/v1/agents/{agent}/streamand start receiving. Resolves on first open; reconnects silently on disconnect with 1s → 30s backoff.ack(messageId)— send an ack on the live WebSocket. No-op when the socket isn't open.close()— stop the reconnect loop and close the socket.
Helpers
buildEnvelope(from, req)— mint an AMP envelope from a short-form request.mintMessageId()/deriveThreadId(subject)/nowTs()— individual minters.shouldWake(status, priority)— presence-aware wake policy, exported so callers can override.parseFrame(raw)—ServerFrame | null; exported for callers that handle the WebSocket themselves.
Errors
Non-ok responses map to typed errors so callers can instanceof-switch without string-matching:
| class | status | when |
| ----------------------- | --------- | --------------------------------------------------- |
| RootNetworkError | — | fetch threw (DNS, connection refused, etc.) |
| RootValidationError | 400 | request body was malformed |
| RootAuthError | 401/403 | missing or invalid token / signature |
| RootNotFoundError | 404 | unknown agent / message |
| RootConflictError | 409 | e.g. manual wake transport, cross-sender id collision |
| RootRateLimitError | 429 | per-agent rate limit; .retryAfterMs when provided |
| RootAgentOfflineError | 503 | wake failed / agent unreachable |
| RootServerError | other 5xx | unclassified server error |
Retry policy
send / sendEnvelope auto-retry on RootNetworkError — up to 3 attempts by default, with 100/200/400 ms exponential backoff capped at 2000 ms. Enabled by RED-297, which made the server-side POST /v1/agents/{id}/message idempotent on envelope id: a retry after a dropped response either finds the stored row and returns 200, or hasn't been stored yet and inserts normally. The same-sender case always resolves cleanly; a cross-sender same-id collision surfaces as RootConflictError (a bug, not a transient).
Tune or disable via retry:
new TaprootClient({
url, agent,
retry: { maxAttempts: 5, baseDelayMs: 50, maxDelayMs: 4000 },
});
// Opt out of retry entirely (wrap your own loop):
new TaprootClient({ url, agent, retry: { maxAttempts: 1 } });No auto-retry on register, heartbeat, wake, poll, ack, or ackHttp — those are caller-driven and easy to wrap.
Non-network errors (4xx, 5xx, rate-limit) never retry; the caller decides whether / when to try again based on the typed error class.
Contributing
npm install
npm run typecheck
npm testLicense
UNLICENSED while the package is pre-release. Contact Redwood Labs for distribution questions.
