Agent SDK
TitenClient is one file of TypeScript over plain fetch — no required dependencies, no transport layer, and one method per route it covers.
Install
bun add titen-memory
npm i titen-memory
pnpm add titen-memory
titen-memory and titen-memory/sdk resolve to the same module. Under Bun you get
src/sdk.ts directly; everywhere else you get dist/npm/sdk.js, which prepack builds with
bun build --target node --format esm. Engines are Node 22+ and Bun 1.2+, and the package
has no required dependencies.
Configure
import { TitenClient } from 'titen-memory/sdk';
const titen = new TitenClient({
url: 'http://127.0.0.1:8787', // trailing slashes are stripped
key: process.env.TITEN_API_KEY!, // sent as `authorization: Bearer …`
// fetch: myWrappedFetch, // optional; defaults to globalThis.fetch
});
Three fields, and fetch is the only seam. There are no interceptors, no retry policy, no
connection pool and no agent option — which is exactly why the same client runs unchanged on
Node 22+, Bun, Deno and edge workers. If you want retries or tracing, wrap fetch and pass
your wrapper in.
Every method resolves to data
The client reads the { data, meta } envelope and returns data. Fields sit at the top
level of what you await:
const obs = await titen.observe({
subject_id: 'user_rama',
kind: 'tool_result',
content: 'Deploy smoke returned 200 for checkout-service.',
source: { type: 'tool', ref: 'deploy_789#smoke' },
trust: 'verified',
});
obs.observation_id; // "obs_56a95360c09c4ee393c53600c4459079"
obs.content_hash; // "bba5cf65e8aee81f…"
meta is dropped on the floor. That includes meta.request_id, meta.replayed on an
idempotent write, and meta.degraded on a compile — the flag that tells you whether the pack
was assembled without vectors or without a model. When you need any of those, call the route
with fetch instead of the client.
The 17 methods
| Health | Route |
|---|---|
health() |
GET /healthz |
ready() |
GET /readyz |
| The memory loop | Route |
|---|---|
resolveProject(reference: string, create = false) |
POST /v1/projects/resolve |
observe(observation: Observation) |
POST /v1/observations |
consolidate(subject_id: string, claims: Claim[], project_id?: string) |
POST /v1/consolidations |
compile(options: CompileOptions) |
POST /v1/context/compile |
feedback(contextId: string, options: FeedbackOptions) |
POST /v1/context/:id/feedback |
evidence(claimId: string) |
GET /v1/claims/:id/evidence |
| Claim lifecycle | Route |
|---|---|
supersede(claimId: string, supersededBy: string, reason?: string) |
POST /v1/claims/:id/supersede |
revoke(claimId: string, reason?: string) |
POST /v1/claims/:id/revoke |
expire(claimId: string, reason?: string) |
POST /v1/claims/:id/expire |
| Checkpoints and keys | Route |
|---|---|
saveCheckpoint(options: CheckpointOptions) |
POST /v1/checkpoints |
getCheckpoint(subject_id: string, kind: string, agent_id?: string) |
GET /v1/checkpoints |
deleteCheckpoint(checkpointId: string) |
DELETE /v1/checkpoints/:id |
createKey(options: { label: string; scopes: string[]; max_trust?: string }) |
POST /v1/keys |
listKeys() |
GET /v1/keys |
revokeKey(keyId: string) |
DELETE /v1/keys/:id |
Observation, Claim, CompileOptions, FeedbackOptions and CheckpointOptions are all
exported, and they carry the enums as literal unions: Observation.kind,
Observation.trust, Observation.visibility, Claim.sources[].relation,
FeedbackOptions.outcome and CheckpointOptions.kind. A typo in any of those is a type
error at build time instead of a 400 at runtime.
Error handling
Any non-2xx response throws TitenError, which carries the status alongside the service’s
own error code and message.
import { TitenClient, TitenError } from 'titen-memory/sdk';
try {
await titen.observe({ subject_id: '', kind: 'tool_result', content: 'x', source: { type: 't' } });
} catch (err) {
if (err instanceof TitenError) {
err.status; // 400
err.code; // "VALIDATION_ERROR"
err.message; // 'Field "subject_id" must be a non-empty string.'
}
}
The three you will meet most: 400 VALIDATION_ERROR with the offending field named,
401 UNAUTHENTICATED for a missing or revoked key, and 403 FORBIDDEN for either a missing
scope (Missing required scope "claims:write".) or a trust ceiling
(This credential may not assert "verified" trust.).
Two edges to know about. code falls back to "UNKNOWN" and the message to
"Request failed" when the body has no error envelope. And the client parses the body
before it checks the status, so a response that is not JSON at all — an HTML error page
from a proxy in front of the service — rejects with the parse error rather than a
TitenError.
The client sends exactly two headers: authorization and, on a body request,
content-type. It never sends Idempotency-Key, so a retry you write yourself is a second
write. FeedbackOptions carries a client_mutation_id field for that reason; Observation
and Claim do not.
What the SDK does not cover
Seventeen methods against 17 of the 58 routes. Everything else — MCP, events and webhooks,
workspaces and memberships, leases and handoffs, policies, channel releases, audit,
federation, export and import, memory views, index drain — has no method. What the client
does wrap is the whole Level 5 loop plus the credential management around it; the
collaboration plane is reached through MCP or through fetch with the same bearer header.
const res = await fetch('http://127.0.0.1:8787/v1/handoffs', {
headers: { authorization: `Bearer ${process.env.TITEN_API_KEY}` },
});
const { data, meta } = await res.json(); // meta.request_id is available here