Lifecycle extensions
createAgent({ extensions: [...] }) accepts named objects implementing GoliathExtension.
All hooks are optional, synchronous or asynchronous, and run sequentially in registration order,
including after* hooks. Names must be nonempty and unique. Factories can return extension objects
to package application-specific configuration; no plugin loader or runtime dependency is needed.
Context and state
Section titled “Context and state”import { createAgent, type GoliathExtension } from "@hellohelen-ai/goliath";
type Context = { timezone: string; allowCloud: boolean };
const contextExtension: GoliathExtension<Context> = { name: "application-context", beforeRun({ context, state }) { state.set("started", Date.now()); return { facts: { timezone: context.timezone } }; }, onFinish({ state, outcome }) { console.log(outcome.status, Date.now() - Number(state.get("started"))); },};
const agent = createAgent<Context>({ model, extensions: [contextExtension] });await agent.run("What is on today?", { context: { timezone: "America/New_York", allowCloud: false },});A concrete context type makes run’s context argument required. Without one, existing calls to
run(ask) still work. Every hook receives runId, conversationId, context, signal, and state, alongside the
phase data below. signal is undefined when none was supplied. Tools receive the application data
as ToolContext.context (typed unknown by default), plus ToolContext.conversationId.
The conversation ID is undefined for default runs and never automatically enters model prompts.
The private state: Map<string, unknown> is allocated independently for each extension and run.
Do not store run counters in shared extension fields. Plain objects and arrays in phase data are
copied; return values are validated before use. Treat opaque values such as errors and class
instances, and the application-owned context, as read-only. They are not cloned or sandboxed.
Context and private state are never automatically serialized into model prompts, memory, traces,
or cloud requests. Explicitly returning a fact or other text makes that selected data visible.
Concurrent calls isolate extension state. Shared memory storage still uses load/save, not
transactions: applications must coordinate overlapping turns that write the same memory.
Every method can return void to preserve the current value. Patches below are explicit field
replacements, except facts, whose keys merge with later values winning.
| Hook | Phase data | Return value besides void |
|---|---|---|
beforeRun |
ask, instructions, facts |
{ ask?, instructions?, facts? } or stop |
afterRecall |
memory |
{ memory }, a transient view for this run |
beforePlan |
ask, tools, contextText, steps, attempt |
{ tools?: string[], contextText?: string } |
afterPlan |
plan, tools, steps, attempt |
{ plan } or stop |
beforeTool |
tool, input, brief, steps |
{ input }, deny, or stop |
afterTool |
tool, input, result, outcome, prior steps |
{ result: string } |
beforeFallback |
request without its signal |
{ request } or stop |
afterAnswer |
text, handledBy, bestEffort, steps |
{ text } or stop |
beforeRemember |
candidate memory, current exchange |
{ memory } or { action: "skip" } |
onError |
original error, origin, partial steps, trace |
Observation only |
onFinish |
outcome, secondary diagnostics |
Observation and cleanup only |
Tools in hook inputs are descriptors with name, description, and writes, not executable
objects. Planning attempts start at zero for each step and increment on a malformed-plan retry.
When no tools remain, planning hooks surround the synthetic answer plan without a planner call.
beforePlan.contextText augments the planner’s instructions for that attempt only; use
beforeRun.instructions for instructions shared with workers and answers.
beforeTool runs once arguments are available and validated, including for no-argument tools and
candidate cached reads. A missing-argument result skips execution and goes directly to
afterTool. Invalid generated arguments request normal escalation. Invalid rewritten arguments
are extension errors and never reach confirmation or execution.
Generated arguments use the AI SDK’s validated output, so schema transformations run once.
Returning { input } supplies a fresh schema input to validate and transform; return void when
preserving the current arguments. No-argument calls are validated locally without a model call.
afterTool.outcome is a discriminated union:
{ status: "executed", output }exposes the successful raw output only during hooks.{ status: "cached", fromStep }identifies the earlier successful read; no raw output exists.{ status: "skipped", reason, extension? }distinguishespolicy,confirmation, andmissing.{ status: "failed", error }describes a tool exception that the conductor can plan around.
Only the transformed result string reaches the step log and tool event. A provisional step is retained for finalization if execution, output formatting, or a hook fails, so an effect is not lost from the diagnostic record. There is no automatic rollback or retry of an executed tool.
Decisions and ordering
Section titled “Decisions and ordering”// A skipped tool step; the conductor may choose another action.return { action: "deny", reason: "Read access is disabled." };
// Finish this run immediately with application-selected text.return { action: "stop", text: "Cloud processing is disabled.", reason: "cloud-disabled" };A denial ends the beforeTool chain and records skipped: true, skipReason: "policy", and the
extension name. It consumes a step. A user confirmation decline has skipReason: "confirmation".
A stop ends the run and adds result.stopped = { extension, phase, reason }. Stop text bypasses
answer transforms and persistence; only finalizers run. handledBy records the actual route:
device before any cloud call, cloud once a handoff has begun. Skip ends beforeRemember
without saving and cannot be reversed by a later hook.
Transformations run in array order. Place argument normalization before policies that need to
inspect final arguments. All rewrites finish before confirm, and execution receives a copy of
the exact approved values. Confirmation callbacks cannot alter the actual call by mutating their
request. Tool filtering intersects the existing set, so later hooks cannot re-enable filtered
names during the same attempt. Replacement plans are checked against the available set.
Cached reads pass through beforeTool; changing arguments invalidates a cache candidate. Failed
and skipped steps never become successful cached reads. Identical calls are detected before
execution, including writes. afterTool changes presentation without changing execution status.
Answers, fallback, and memory
Section titled “Answers, fallback, and memory”afterAnswer runs once a nonempty final device, cloud, or best-effort answer exists, before its
answer event, answer step (when present), saved exchange, and returned text. All carry the same
transformed text. Returning an empty answer is an extension error, not a request for model retry.
An empty provider-guardrail result stays on device and does not invoke answer or fallback hooks.
beforeFallback runs only when a fallback will actually be called. Its request is a copy, so
redacting it does not alter the local step log or persistence base. Preserve the escalation
reason; the signal belongs to the run and cannot be replaced through the payload. Cloud-only
session fallback uses the same start, recall, policy, answer, memory, and finalization hooks.
afterRecall edits are transient. Persistence starts from the originally loaded memory, the
current ask and transformed answer, and the scribe’s candidate. beforeRemember can replace that
candidate or skip saving. Best-effort answers retain the existing behavior of not being saved.
After a model-error handoff, including session fallback, Goliath retains the previous summary and
the latest three exchanges without asking the failed device to summarize. Older exchanges are
dropped on this route. Normal routes use the scribe to fold evicted exchanges into the summary.
A scribe or memory-save failure preserves the completed answer and emits memory-error; it never
executes the turn again in cloud. A failed tool formatter retains the full output and substitutes
a bounded completion message. These recovered failures finish as completed outcomes.
Errors, cancellation, and limits
Section titled “Errors, cancellation, and limits”Transformation/decision hook failures reject with exported GoliathExtensionError, including
extension, phase, and cause. Application errors retain their original rejection values;
onError.origin distinguishes extension, model, memory, confirmation, fallback, formatter,
event, config, budget, tool, and harness errors. Only provider failures in the active device loop
request model-error fallback. Hook errors do not increment the session model-failure counter.
onFinish runs once per extension for completed, stopped, error, and aborted outcomes, including
extensions whose earlier hooks were not reached. Errors and aborts also invoke onError first.
Observer failures never replace the original result or rejection and never prevent later
observers from running. Later finalizers can inspect accumulated secondary diagnostics;
successful or stopped results retain them in result.diagnostics.
Cancellation is checked around awaited hooks and before effects. An already-aborted run still notifies error/finalization hooks. Callbacks receive the signal and should cooperate with it; the harness cannot forcibly interrupt arbitrary JavaScript or undo a started side effect. Cleanup hooks still run after cancellation. There are no automatic hook timeouts.
Tool result strings, including extension replacements, are capped at 600 characters. Every model
call budgets the rendered prompt and output schema after transformations, using countTokens
when configured or a conservative estimate otherwise. Input is capped at 70% of the window or
less to reserve each phase’s output cap and provider headroom. With extensions configured,
oversized active-loop prompts reject with exported GoliathBudgetError before generation;
without extensions, they escalate as context-budget. Scribe failures preserve completed answers.
Native counts still require headroom for the provider’s final transcript formatting.
Runs within one conversation are serialized, including their hooks; different conversations may run concurrently on the same agent. Extension state remains private to each run. Hooks must not wait for another run in the same conversation to start or finish.
Saved summaries are capped at one eighth of the window using the token estimator, and saved
recent history is limited to three exchanges, including after memory transformations. Registering
an extension itself adds no model tokens or model calls. The old compressors option remains
accepted for compatibility but is deprecated because it was never invoked; use afterTool and
beforePlan instead. Model wrappers, shell hooks, and plugin discovery are outside this API.