Add a capability tool¶
A capability tool is a small, stateless service that owns one heavy
provider-specific job — rendering a PDF, running OCR, generating a
thumbnail — that doesn't belong in the hadron-server core. The core
stays the front door (identity, access control, decryption, node
loading) and calls the tool over the private network for the heavy part.
The model and its rationale are covered in
Capability tools; this guide is the
recipe for adding a new one.
The first tool, hadrontool-pdf, established the pattern. Follow the same
rails and the next tool drops in without touching the core's privacy or
access-control code.
When a capability belongs in a tool¶
Reach for a separate tool when the capability:
- Isolates a heavy or noisy dependency — a headless browser, a native binary, a model runtime — that would bloat the API image or drag the API process into lifecycle management.
- Is a pure function of its inputs — same input, same output. No database, no per-request identity, no graph access.
- Needs no secrets or access-control logic of its own — everything that needs identity, keys, or the graph stays in the core.
If the work needs the database, the decryption keys, or the access-control rules, it is not a capability tool — it belongs in the core. The boundary is the whole point: only already-authorized, already-plaintext data crosses to the tool.
Step 1: Build the tool as a stateless service¶
The tool lives in its own repository and ships as its own container. It exposes a narrow HTTP surface:
- A handful of
POSTendpoints that each do one job (/convert/…,/ocr/…), taking JSON in and returning JSON (or bytes) out. - Health and info endpoints —
/healthz(liveness),/readyz(readiness, including whether any warm-up like a browser launch has completed), and an unauthenticated/…/infothat reports capabilities without exposing secrets. - Shared bearer-token auth on the working endpoints, matched in constant time. In production the tool refuses to start without its token set; leaving it unset is a development-only pass-through.
It holds no database, no keys, and no access-control logic. See the PDF service HTTP API for a complete worked contract — endpoints, request/response shapes, errors, and limits — to model your tool's surface on.
Step 2: Write the thin server-side client¶
hadron-server reaches the tool through a small client module at
src/lib/<tool>Client.ts. Its whole job is to POST to the tool with a
timeout and map every failure to a typed GraphQLError, so a resolver
that calls it degrades gracefully when the tool is misconfigured, down, or
slow — rather than throwing an opaque fetch error at the caller.
The shape, distilled from
src/lib/pdfClient.ts:
import { GraphQLError } from 'graphql';
interface ThingResult {
result: string;
}
const DEFAULT_TIMEOUT_MS = 30_000;
const MAX_INPUT_CHARS = 1_000_000;
export async function doThing(input: string): Promise<ThingResult> {
// 1. Caller-input checks FIRST — a bad request must not read as an outage.
if (input.length > MAX_INPUT_CHARS) {
throw new GraphQLError('Input exceeds the conversion cap.', {
extensions: { code: 'THING_TOO_LARGE', length: input.length, max: MAX_INPUT_CHARS },
});
}
// 2. Config check → typed NOT_CONFIGURED, never a raw crash.
const baseUrl = process.env.THING_SERVICE_URL;
if (!baseUrl) {
throw new GraphQLError('Thing is not configured (THING_SERVICE_URL unset).', {
extensions: { code: 'THING_SERVICE_NOT_CONFIGURED' },
});
}
const token = process.env.THING_SERVICE_TOKEN;
const url = `${baseUrl.replace(/\/+$/, '')}/do/thing`;
// 3. Bounded call — abort on timeout so a hung tool can't wedge a request.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
let res: Response;
try {
res = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ input }),
signal: controller.signal,
});
} catch (err) {
// 4. Network failure / timeout → typed UNREACHABLE.
throw new GraphQLError(
controller.signal.aborted ? 'Thing service timed out.' : 'Thing service is unreachable.',
{ extensions: { code: 'THING_SERVICE_UNREACHABLE', cause: String(err) } },
);
} finally {
clearTimeout(timer);
}
// 5. Non-2xx → typed ERROR, carrying a truncated body for diagnosis.
if (!res.ok) {
const detail = await res.text().catch(() => '');
throw new GraphQLError(`Thing service error (${res.status}).`, {
extensions: { code: 'THING_SERVICE_ERROR', status: res.status, detail: detail.slice(0, 500) },
});
}
// 6. Malformed success payload → typed BAD_RESPONSE.
const payload = (await res.json().catch(() => null)) as ThingResult | null;
if (!payload || typeof payload.result !== 'string') {
throw new GraphQLError('Thing service returned an unexpected response.', {
extensions: { code: 'THING_SERVICE_BAD_RESPONSE' },
});
}
return payload;
}
Note the ordering: the client validates caller errors before infra
errors. The oversized-input cap (step 1) throws THING_TOO_LARGE
before the config check, so a malformed or too-large request is rejected
as a bad request even when the service URL is unset — it never
masquerades as a NOT_CONFIGURED outage, and an optional resolver won't
silently degrade a call that was the caller's fault.
The typed error contract¶
Every client failure carries a code in the GraphQLError extensions, so
callers branch on the code instead of parsing message strings:
The codes follow one scheme — <TOOL>_SERVICE_<STATE>, matching the
<TOOL>_SERVICE_URL / <TOOL>_SERVICE_TOKEN env vars — so the sample
above (THING_SERVICE_…), this table, and the resolver guidance below all
name the same strings a caller must catch:
code |
When it fires | What the caller should do |
|---|---|---|
<TOOL>_SERVICE_NOT_CONFIGURED |
The <TOOL>_SERVICE_URL env var is unset. |
Treat the capability as unavailable; surface a "not enabled" state, don't error the whole request. |
<TOOL>_SERVICE_UNREACHABLE |
The fetch threw, or the request timed out (AbortController). |
Transient — the tool is down or slow. Safe to retry or fall back. |
<TOOL>_SERVICE_ERROR |
The tool returned a non-2xx status. | The tool rejected the input; status + truncated detail say why. |
<TOOL>_SERVICE_BAD_RESPONSE |
2xx, but the body wasn't the expected shape. | Version skew between core and tool — check both are deployed. |
Caller-input failures use their own codes (e.g. <TOOL>_TOO_LARGE),
distinct from the _SERVICE_ infra codes above.
Word the human-readable messages per direction if one client serves
several operations, so (for example) an import path doesn't surface a
"PDF export" error. The pdfClient does exactly this.
Step 3: Configure via env¶
Two env vars, read by the client at call time:
| Var | Required | Purpose |
|---|---|---|
<TOOL>_SERVICE_URL |
Yes | Base URL, e.g. http://hadrontool-thing:8080. Unset → the capability is off and the client throws <TOOL>_SERVICE_NOT_CONFIGURED. |
<TOOL>_SERVICE_TOKEN |
Prod | Shared bearer token. Sent as Authorization: Bearer … when set. |
Because an unset URL is a clean typed error rather than a crash, a core deployment that hasn't enabled the tool keeps working — the feature that needs it is simply unavailable.
Step 4: Deploy it internal-only¶
A capability tool is deployed as a separate, internal-only container:
- It runs on the platform's private network and is reached by service
name (
http://hadrontool-thing:8080). It is not publicly routed — no host port, no reverse-proxy route, no public DNS. - The core authenticates to it with the shared bearer token; the tool authenticates nothing else, because only the core can reach it and only already-authorized data crosses the boundary.
- Secrets (the token) are injected at runtime, not baked into the image.
The concrete build-and-deploy mechanics (image registry, orchestration,
secret injection) are platform-operations detail and live with the tool,
not on this public site — see the tool's own repository and its ops
memory (for the PDF service, the hadrontool-pdf memory's
ops:deployment node). What matters for the pattern is the shape above:
separate repo, private-network container, no public route, secrets at
runtime.
Step 5: Degrade gracefully in the resolver¶
Because the client throws typed errors, the resolver that calls it can decide per-capability how much a failure matters:
- Optional enhancement (a nice-to-have render): catch
<TOOL>_SERVICE_NOT_CONFIGURED/<TOOL>_SERVICE_UNREACHABLEand return the request's core result without the extra, so the tool being down never breaks the primary operation. - Required for the operation (the whole request is "make a PDF"): let the typed error propagate — the caller gets an actionable code, not a stack trace.
Either way, the core never hard-depends on a tool being up. That is the payoff of routing every failure through the typed client.
New-tool checklist¶
- Repo + container — a separate repository, its own Dockerfile, stateless (no DB, no keys, no ACL).
- Capability contract — narrow
POSTendpoints,/healthz,/readyz, unauthenticated/…/info, bearer-token auth on the working routes. - Server-side client —
src/lib/<tool>Client.tswith a timeout and typedGraphQLErrors (<TOOL>_SERVICE_NOT_CONFIGURED/_UNREACHABLE/_ERROR/_BAD_RESPONSE); caller-error checks before infra checks. - Config —
<TOOL>_SERVICE_URL+<TOOL>_SERVICE_TOKEN, read at call time. - Deployment — internal-only container on the private network, no public route, token injected at runtime.
- Graceful degradation — resolvers catch the typed codes and decide whether the capability is optional or required.
What's still being generalized¶
Today the core reaches each tool through a single hard-wired URL. The capability-tool protocol — how a tool advertises what it can do, how the core routes when more than one tool offers the same capability, and how permission to use a capability is granted — is still being worked out. The transport is also slated to move from direct HTTP to the platform message bus (NATS request/reply); because a tool's operations are pure functions, they wrap behind a subject without changing the core. Build your tool to the HTTP contract above for now; the routing and transport generalizations will layer on without changing the tool's function signatures.
Related¶
- Capability tools — the model and the boundary rationale behind this recipe.
- PDF service HTTP API — the first tool's complete contract, to model your endpoints on.
- Architecture — the core front-door hierarchy the boundary is pushed one layer out from.