FileTaskStore
JavaScript · package toolnexus · SPEC §7B · js/src/serve.ts
interface TaskStore { get(id: string): Promise<A2ATask | undefined> save(task: A2ATask): Promise<void>}
class InMemoryTaskStore implements TaskStore { /* default — process lifetime only */ }class FileTaskStore implements TaskStore { constructor(dir: string)}
function resolveStore(store?: TaskStore | "memory" | string): TaskStorePluggable persistence for the Tasks a served toolkit creates. Every SendMessage/GetTask in
startA2AServer goes through a TaskStore — InMemoryTaskStore by
default (Tasks live only for the process’s lifetime), FileTaskStore for one JSON file per Task id
on disk, or your own object implementing the two-method interface (a NATS/JetStream-backed store,
a Postgres table — anything).
When to use it
Section titled “When to use it”Reach for FileTaskStore (or a2a.store: "file:<dir>") the moment a served agent’s Tasks need to
survive a process restart — a peer polling GetTask mid-poll when you redeploy should still find
its Task, not a 404. InMemoryTaskStore is fine for local development and anything ephemeral.
Why this and not the alternative
Section titled “Why this and not the alternative”Prefer the shipped stores when:
InMemoryTaskStore— you don’t need durability; it’s also whatresolveStore()/resolveStore("memory")gives you with no setup.FileTaskStore— you want durability with zero infrastructure: one directory, one file per Task id, atomic writes (.tmp+ rename, so a concurrentget()never reads a half-written file).
Examples
Section titled “Examples”1. The smallest useful call — round-trip one Task on disk
Section titled “1. The smallest useful call — round-trip one Task on disk”import assert from "node:assert"import fs from "node:fs"import os from "node:os"import path from "node:path"import { FileTaskStore } from "toolnexus"
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tn-taskstore-"))const store = new FileTaskStore(dir)
await store.save({ id: "abc-123", status: { state: "completed" }, artifacts: [{ parts: [{ kind: "text", text: "hi" }] }] })const got = await store.get("abc-123")
assert.equal(got!.status.state, "completed")assert.equal((got!.artifacts as any)[0].parts[0].text, "hi")assert.ok(fs.existsSync(path.join(dir, "abc-123.json")), "one JSON file per task id")assert.equal(await store.get("missing"), undefined)
fs.rmSync(dir, { recursive: true, force: true })console.log("ok: round-tripped abc-123")2. A realistic case — resolveStore maps every selector
Section titled “2. A realistic case — resolveStore maps every selector”serve()’s a2a.store field accepts the same three shapes resolveStore normalizes: omitted/
"memory", "file:<dir>", or a pre-built object.
import assert from "node:assert"import fs from "node:fs"import os from "node:os"import path from "node:path"import { FileTaskStore, InMemoryTaskStore, resolveStore } from "toolnexus"
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tn-taskstore-"))
assert.ok(resolveStore() instanceof InMemoryTaskStore, "omitted ⇒ in-memory")assert.ok(resolveStore("memory") instanceof InMemoryTaskStore)assert.ok(resolveStore(`file:${dir}`) instanceof FileTaskStore, "\"file:<dir>\" ⇒ FileTaskStore")
const custom = new InMemoryTaskStore()assert.equal(resolveStore(custom), custom, "an already-built store is used as-is")
assert.throws(() => resolveStore("redis:whatever"), /Unknown A2A store/)
fs.rmSync(dir, { recursive: true, force: true })console.log("ok: every selector resolved")3. The full surface — a served toolkit backed by a host-supplied store
Section titled “3. The full surface — a served toolkit backed by a host-supplied store”serve() routes every save/get through whatever TaskStore you give it — here a small in-memory
spy proving every write hits the host store, not some hidden default.
import assert from "node:assert"import http from "node:http"import { createClient, createToolkit, type A2ATask } from "toolnexus"
class SpyStore { saved: string[] = [] private map = new Map<string, A2ATask>() async save(t: A2ATask) { this.saved.push(t.status.state); this.map.set(t.id, t) } async get(id: string) { return this.map.get(id) }}
const llm = http.createServer((_req, res) => { res.writeHead(200, { "content-type": "application/json" }) res.end(JSON.stringify({ choices: [{ message: { content: "DONE" } }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }))})await new Promise<void>((r) => llm.listen(0, "127.0.0.1", r))const llmPort = (llm.address() as any).port
const tk = await createToolkit({ builtins: false })const client = createClient({ baseUrl: `http://127.0.0.1:${llmPort}`, style: "openai", model: "x", apiKey: "k" })const store = new SpyStore()const srv = await tk.serve("127.0.0.1:0", { client, a2a: { name: "audited-desk", store } })
try { const send = await fetch(srv.url + "/", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: "1", method: "SendMessage", params: { message: { role: "user", parts: [{ kind: "text", text: "go" }] } } }), }).then((r) => r.json()) as any
const id = send.result.id for (let i = 0; i < 50 && store.saved.at(-1) !== "completed"; i++) await new Promise((r) => setTimeout(r, 10))
assert.deepEqual(store.saved, ["submitted", "working", "completed"], "every state transition went through the host store") console.log("ok:", store.saved.join(" → "))} finally { await srv.stop() await tk.close() llm.close()}Options
Section titled “Options”| Field | Type | What it does |
|---|---|---|
new FileTaskStore(dir) |
dir: string |
Directory to hold one <sanitized-id>.json per Task; created if missing. |
resolveStore(store) |
TaskStore | "memory" | string | undefined |
Normalizes a serve() a2a.store value into a concrete TaskStore. |
What you get back
Section titled “What you get back”TaskStore — { get(id): Promise<A2ATask | undefined>, save(task): Promise<void> }. FileTaskStore
writes are atomic (.tmp file then rename), so a concurrent get() never observes a half-written
Task mid-poll.
See also
Section titled “See also”startA2AServer— Publish an Agent Card and answer JSON-RPC over the client loop — your toolkit becomes someone else’s remote agent.buildAgentCard— Construct the Agent Card that advertises your name, skills and endpoint.buildMcpServer— The inbound MCP profile: any MCP client can call your tools.