@johnhenry/a2a-query-tanstack
npm install @johnhenry/a2a-query-tanstack @johnhenry/a2a-query @tanstack/react-query<!-- In <head>, before any <script type="module"> or modulepreload. --><script type="importmap"> { "imports": { "@johnhenry/a2a-query-tanstack": "https://cdn.jsdelivr.net/npm/@johnhenry/[email protected]/dist/index.js", "@tanstack/query-core": "https://cdn.jsdelivr.net/npm/@tanstack/[email protected]/build/modern/index.js", "@tanstack/react-query": "https://cdn.jsdelivr.net/npm/@tanstack/[email protected]/build/modern/index.js", "react": "https://cdn.jsdelivr.net/npm/[email protected]/+esm", "react/jsx-runtime": "https://cdn.jsdelivr.net/npm/[email protected]/jsx-runtime/+esm" } }</script><script type="module"> import * as a2aQueryTanstack from "@johnhenry/a2a-query-tanstack";</script>queryOptions/mutationOptions factories that delegate fetching to a2a-query while syncing its own reactive cache into TanStack Query’s — same shape as mcp-query-tanstack.
import { QueryClient, useQuery, useMutation } from '@tanstack/react-query';import { A2AQuery } from '@johnhenry/a2a-query';import { a2aqTaskQueryOptions, a2aqCardQueryOptions, a2aqSendMessageMutationOptions,} from '@johnhenry/a2a-query-tanstack';
const queryClient = new QueryClient();const client = new A2AQuery({ agents: { support: { url: 'https://support.example.com/a2a' } } });
function TaskView({ agent, taskId }: { agent: string; taskId: string }) { const { data: card } = useQuery(a2aqCardQueryOptions(client, agent, { staleTime: Infinity })); const { data: task } = useQuery(a2aqTaskQueryOptions(client, agent, taskId, { staleTime: Infinity })); const send = useMutation(a2aqSendMessageMutationOptions(client, queryClient, agent));
return ( <section> <h2>{card?.name}</h2> <p>{task?.status?.state}</p> <button onClick={() => send.mutate(makeMessage('continue'))}>Send</button> </section> );}The three factories
Section titled “The three factories”a2aqTaskQueryOptions(client, agent, taskId, opts?)
Section titled “a2aqTaskQueryOptions(client, agent, taskId, opts?)”Returns queryOptions<Task> under the key ['a2a-query', agent, 'task', taskId]. The queryFn registers the sync bridge for this key, then resolves client.task(agent, taskId) and returns the handle’s cached task snapshot — so the first fetch goes through a2a-query (deduped, cached, poll/push-maintained there), and every subsequent update arrives via the bridge, not via TanStack refetching.
Only mount this for a taskId you actually hold — one returned by sendMessage or an existing TaskHandle. For an unknown id the snapshot is empty, the queryFn returns undefined, and TanStack surfaces its generic “query data cannot be undefined” error rather than anything 404-shaped.
a2aqCardQueryOptions(client, agent, opts?)
Section titled “a2aqCardQueryOptions(client, agent, opts?)”Returns queryOptions<AgentCard> under ['a2a-query', agent, 'card']. opts.refresh: true forwards to client.card(agent, { refresh: true }), forcing a network re-fetch of the card every time the queryFn runs — combine it with a long staleTime or you’ve built a card-refetch-on-every-focus loop. Without refresh, refetches are served from a2a-query’s cache.
a2aqSendMessageMutationOptions(client, queryClient, agent, opts?)
Section titled “a2aqSendMessageMutationOptions(client, queryClient, agent, opts?)”Returns mutationOptions whose mutationFn is (message) => client.sendMessage(agent, message, opts). On success, if the reply is a TaskHandle (detected by its methods — a plain Message reply also carries a taskId field, so key presence alone can’t distinguish them), it invalidates that task’s own query key. A Message reply touches nothing.
Note this factory takes the QueryClient explicitly — it needs it inside onSuccess, where TanStack doesn’t hand one over.
Query keys and invalidation
Section titled “Query keys and invalidation”Keys are namespaced agent-first, and artifacts nest under their task, so prefix invalidation composes:
| Target | Key | Helper |
|---|---|---|
| One task | ['a2a-query', agent, 'task', taskId] |
taskQueryKey(agent, taskId) |
| One artifact | ['a2a-query', agent, 'task', taskId, 'artifact', artifactId] |
artifactQueryKey(agent, taskId, artifactId) |
| An agent’s card | ['a2a-query', agent, 'card'] |
cardQueryKey(agent) |
| Everything for one agent | ['a2a-query', agent] |
— |
| The whole namespace | ['a2a-query'] |
A2A_QUERY_NS |
Invalidating a task’s prefix therefore also catches its artifact entries. tagToQueryKeyPrefix(tag) maps a2a-query cache tags (agent:, card:, task:, artifact:) to these prefixes — it’s exported and pure, but as of v1 nothing wires it up: for actively-bridged queries the sync bridge makes tag-wide invalidation redundant, and it’s reserved for a future release covering TanStack-inactive queries.
How the sync bridge works
Section titled “How the sync bridge works”Every queryOptions() factory lazily registers a listener on a2a-query’s own cache the first time a query actually runs (ensureSynced, or call attachA2aqSync(client, queryClient) explicitly up front). From then on, task/card refetches, protocol push events folded via ingestPush, and optimistic patch()/rollback all mirror straight into TanStack Query’s cache via setQueryData — no extra network round-trip. client.cache is a real @johnhenry/agent-query-core QueryCache, not a fork, so this bridge consumes its stable, public subscribe/getSnapshot API directly.
TanStack’s own staleTime/gcTime still apply as a safety margin on top — but for an actively-rendered bridged query, a2a-query’s cache is the source of truth. When TanStack garbage-collects a query nobody renders anymore (a QueryCache 'removed' event), the bridge releases its a2a-query-side subscription too, so nothing leaks. If the same query mounts again later, the next queryFn run re-registers it.
Staleness and refetch traps
Section titled “Staleness and refetch traps”- Set a long
staleTimeon bridged queries. Freshness is a2a-query’s job (taskPollMspolling, streaming, push) and arrives through the bridge; TanStack’s defaultstaleTime: 0just makes every remount and window focus re-run thequeryFn. Those re-runs are served from a2a-query’s cache — cheap, but pointless, and withrefresh: trueon a card query they’re real network calls.staleTime: Infinityis the honest setting. - You can’t override
queryKeyorqueryFnthroughopts. The factories spread youroptsfirst, then set both — a customqueryFnor key passed in is silently discarded. Everything else (staleTime,gcTime,enabled,select, …) passes through. - Optimistic updates belong on the a2a-query side. Don’t reimplement
onMutate/rollback against TanStack’s cache — call a2a-query’s ownpatch()mechanism and the bridge propagates both the patch and any rollback, exactly like a server-confirmed write. gcTimebounds bridge teardown, not data freshness. An aggressivegcTimemeans unmount → gc → bridge unsubscribed; a remount silently re-registers, at the cost of onequeryFnpass through a2a-query’s cache.
Verification/simulation-shaped: this package only bridges reads (task, card) and the one send mutation. respond/cancel/artifacts aren’t wrapped in queryOptions/mutationOptions yet — use A2AQuery/TaskHandle directly for those (artifactQueryKey exists so your own artifact wrappers land under the right prefix).