Post-tool tier
The contributePostToolTier hook (C10 plugin slot) — uniform enrichment across tool batches for citations, entities, quality scores, and safety flags.
contributePostToolTier is the plugin slot for uniform enrichment
that runs after every batch of tool calls completes. The same
hook covers citations, entity extraction, quality scoring,
safety flags — anything that should fire across whatever tool
ran without baking the logic into each tool definition.
It's the seam vertical-AI-startup plugins reach for when they want to add a vertical-specific enrichment pass without forking the substrate or touching every tool.
The contract
contributePostToolTier?(): PostToolTierExecutor | null | undefined
type PostToolTierExecutor = (
completedTools: readonly CompletedToolCall[],
) => Promise<readonly PostToolTierRow[]>;One contribution per plugin (first-wins on the hook level — multiple plugins compose at the registration-order level inside the substrate's compiled graph). The executor receives the full batch of completed tool calls for the turn and returns one or more typed rows. The substrate writes those rows alongside the tool results in the event log.
The 4 stage hooks
Inside the compiled graph, the post-tool tier dispatches into 4 named stages — each one is agnostic-by-injection: the node body is domain-free; the host (or a plugin) supplies the executor at runtime.
| Stage | Executor field | What it produces |
|---|---|---|
enrichment | config.enrichmentExecutor | Domain-specific enrichment rows (entity recognition, vertical lookups) |
safetyReview | config.safetyReviewExecutor | Safety flags, refusal triggers, policy-bound rewrites |
quality | config.qualityEvaluator | Quality scores against the tool batch |
citation | config.citationExtractor | Citation rows attributed to source tool calls |
contributePostToolTier is the plugin-facing form; the four
config.*Executor fields are the equivalent at runtime
construction time. Hosts can wire either way:
// Plugin-facing
const myPlugin = definePleachPlugin({
capabilities: {
_raw: {
contributePostToolTier: () => myEntityRecognizer,
},
},
});
// Runtime-construction-facing (host strategy)
const runtime = new SessionRuntime({
enrichmentExecutor: myEntityRecognizer,
citationExtractor: myCitationExtractor,
// …
});The plugin-facing path is the recommended one for any plugin that ships outside the host repository — it composes the same as any other contribution, and the host doesn't have to know about the plugin's enrichment to wire it.
Worked example: vertical entity recognizer
The canonical reference is
examples/plugins/domain-entity-recognition/
— a fictional "legal contracts" vertical that recognizes three
entity types:
| Entity type | What it matches |
|---|---|
party | Capitalized name(s) ending in Corp. / Inc. / LLC / Industries / Holdings |
money | $ followed by digits with commas + optional decimals |
date | ISO-8601 YYYY-MM-DD |
const PARTY_RE =
/\b([A-Z][A-Za-z&.'-]*(?:\s+[A-Z][A-Za-z&.'-]*)*\s+(?:Corp\.?|Inc\.?|LLC|Ltd\.?|Industries|Holdings|Group|Partners|Company|Co\.))/g;
const MONEY_RE = /\$\d{1,3}(?:,\d{3})*(?:\.\d+)?/g;
const DATE_RE = /\b\d{4}-\d{2}-\d{2}\b/g;
const contributePostToolTier = () => {
return async (completedTools) => {
const out = [];
completedTools.forEach((tool, toolIndex) => {
const result = extractResult(tool);
const text = extractText(result);
if (text === null) return;
const toolCallId = extractToolCallId(tool, toolIndex);
for (const m of text.matchAll(PARTY_RE)) {
out.push({
kind: "entity",
type: "party",
value: m[1].trim(),
toolCallId,
index: m.index ?? 0,
});
}
for (const m of text.matchAll(MONEY_RE)) {
out.push({
kind: "entity",
type: "money",
value: m[0],
toolCallId,
index: m.index ?? 0,
});
}
for (const m of text.matchAll(DATE_RE)) {
out.push({
kind: "entity",
type: "date",
value: m[0],
toolCallId,
index: m.index ?? 0,
});
}
});
return out;
};
};
export const legalContractEntityPlugin = {
name: "example-legal-contract-entity-recognition",
version: "1.0.0",
contributePostToolTier,
};The runnable version (with the duck-typed result extractor and a
node --test smoke that extracts entities from a sample
contract) lives at
examples/plugins/domain-entity-recognition/.
The toolCallId attribution invariant
Every row the executor returns SHOULD include a toolCallId
field. The runtime narrows the enrichment rows back to their
originating tool call when projecting the event log — multi-tool
batches without toolCallId attribution collapse to "produced
by some tool in this batch," and consumer-facing surfaces
(citations, lineage diagrams, audit rows) can't render
attribution cleanly.
The example's extractToolCallId(tool, fallbackIndex) returns
either the tool's own toolCallId, its id, or a
unknown-${index} fallback so production code never silently
drops attribution.
The duck-typed result reader
Tool results across the substrate have several shapes:
function extractText(toolResult) {
if (typeof toolResult === "string") return toolResult;
if (toolResult === null || typeof toolResult !== "object") return null;
if (typeof toolResult.text === "string") return toolResult.text;
if (typeof toolResult.content === "string") return toolResult.content;
if (toolResult.result && typeof toolResult.result === "object" &&
typeof toolResult.result.text === "string") {
return toolResult.result.text;
}
if (toolResult.output && typeof toolResult.output === "object" &&
typeof toolResult.output.text === "string") {
return toolResult.output.text;
}
return null;
}Production post-tool-tier executors cover at least these four shapes. Different tools normalize to different result envelopes, and the post-tool-tier seam is the one place that has to handle all of them in one place.
Composition across the 4 stages
The four stages dispatch in fixed order:
enrichment → safetyReview → quality → citation. Each stage's
output is visible to the next stage; the substrate threads them
through a shared context. Plugins targeting one stage don't
collide with plugins targeting another — a vertical entity
recognizer (enrichment) composes cleanly with a refusal-pattern
detector (safetyReview) and a citation extractor (citation).
If two plugins target the same stage, registration order wins — the first plugin's executor fires first; the second sees the output of the first as context but produces its own rows independently.
Honest scope-limits
- The hook is async. Unlike
onChunk(which is sync for replay-determinism reasons),contributePostToolTierruns after the tool batch completes — there's no streaming constraint, and the executor can call out to an API, a model, a database, etc. - One enrichment pass per turn. The executor fires once
after the tool batch, not once per tool. If your vertical
needs per-tool dispatch, do it inside the executor by walking
completedTools(the example does this). - No mutation of tool results. The executor produces new rows;
it doesn't rewrite the tool results that went into the event
log. If you need to rewrite tool output before the substrate
reads it, that's a stream observer
amendverdict, not a post-tool-tier executor. - Not a graph-node replacement. The four stages exist as
named slots in the compiled graph.
extraGraphNodesis the hook for adding new nodes to the lattice;contributePostToolTierfills existing slots in the lattice.
What this enables for verticals
The four stages cover the typical vertical's enrichment needs:
| Vertical | Stage(s) reached |
|---|---|
| Legal contract review | enrichment (entity recognition: parties / money / dates) + citation (clause references) |
| Medical chart review | enrichment (ICD codes / drug names) + safetyReview (drug-interaction warnings) |
| Regulatory filing | enrichment (form fields / dates) + quality (completeness scoring) |
| Code review | enrichment (symbol names / file paths) + safetyReview (license violations) |
The pattern is identical: swap the recognizer body, keep the seam. The plugin contract guarantees the host doesn't have to know about the vertical for the wiring to work.
Where to go next
Routing decisions
The registerIntentLabel() API and custom routing-override surface — the v1.x roadmap for plugin-driven intent registration and per-call routing override.
Runtime strategies
Typed strategy slots on SessionRuntimeConfig — single-shape, single-consumer fields the host fills with domain code. The companion surface to the plugin contract.