Lifecycle
The @pai/core lifecycle hook API.
Package: @pai/core
Lifecycle hooks customize agent execution without exposing a generic runtime middleware chain.
Hooks return structured decisions. They do not receive raw storage write access and should not mutate persisted message records directly. When durable thread state must change, use the runtime-owned thread facade or an explicit trusted/admin API so the runtime can preserve scope checks, revisions, leases, realtime invalidation, and provider portability.
import {
composeLifecycle,
type AgentLifecycle,
type ModelCallPatch,
type ModelProviderOptions,
type ModelSettings,
} from "@pai/core";
import type {
Instructions,
LanguageModel,
ModelMessage,
ToolChoice,
ToolSet,
} from "ai";AgentLifecycle
type AgentLifecycle<
TRuntimeContext = unknown,
TContract extends AgentContract = AgentContract,
> = {
/** Optional helper name used by diagnostics and composed lifecycle traces. */
name?: string;
/**
* Prepare or short-circuit one execution episode before model steps start.
*
* Use this for guardrails, quota checks, cache hits, experiment routing,
* model-step ceilings, and model-only context rebuilt for each send or resume
* episode.
*/
prepareRun?: (
input: PrepareRunInput<TRuntimeContext, TContract>,
) => MaybePromise<PrepareRunResult | void>;
/** Resolve durable/base context for one model step as lazy middleware. */
resolveModelContext?: ResolveModelContextHook<TRuntimeContext, TContract>;
/** Prepare metadata patches that commit atomically with a history rewrite. */
prepareHistoryRewrite?: PrepareHistoryRewriteHook<TRuntimeContext, TContract>;
/** Observe a committed history rewrite for best-effort reconciliation. */
historyRewritten?: HistoryRewrittenHook<TRuntimeContext, TContract>;
/**
* Prepare one model step.
*
* Use this for RAG, model routing, per-step shaping of already-enabled tools,
* provider options, prompt-cache settings, and guardrails before a model call.
*/
prepareModelStep?: (
input: PrepareModelStepInput<TRuntimeContext, TContract>,
) => MaybePromise<PrepareModelStepResult | void>;
/**
* Wrap one model provider call.
*
* Use this for conditional routing, model-call caching, attempt-scoped
* controls, and stream handling around the underlying model operation.
*/
aroundModelCall?: (
input: Readonly<ModelStepRequest<TContract, TRuntimeContext>>,
next: ModelCallNext,
) => Promise<ModelStepStreamResult>;
/**
* Transform the assistant step stream before it is assembled and committed.
*
* This is the canonical output transform. Changes made here affect the live
* stream, persisted message, refreshed snapshots, and future model context.
*/
transformStepStream?: (
input: TransformStepStreamInput<TRuntimeContext, TContract>,
) => MaybePromise<ReadableStream<StepStreamChunk>>;
/**
* Inspect, replace, skip, or reject a model-requested tool call before it is
* executed, routed to a client, or suspended.
*/
beforeToolCall?: (
input: BeforeToolCallInput<TRuntimeContext, TContract>,
) => MaybePromise<BeforeToolCallResult | void>;
/**
* Inspect or replace any tool result before it returns to the model.
*/
afterToolResult?: (
input: AfterToolResultInput<TRuntimeContext, TContract>,
) => MaybePromise<AfterToolResultResult | void>;
/**
* Run after one assistant step has been assembled, before the final
* step/run transition is committed.
*
* Use this for execution-local follow-up context or custom completion
* policy. It cannot rewrite the assembled assistant message.
*/
afterStep?: (
input: AfterStepInput<TRuntimeContext, TContract>,
) => MaybePromise<AfterStepResult | void>;
};TRuntimeContext is the type returned by the agent's runtimeContext builder. Each phase
also has a named callable alias: PrepareRunHook, ResolveModelContextHook,
PrepareHistoryRewriteHook, HistoryRewrittenHook, PrepareModelStepHook,
AroundModelCallHook, TransformStepStreamHook, BeforeToolCallHook,
AfterToolResultHook, and AfterStepHook.
Use TRuntimeContext = unknown for a reusable hook that requires no context fields.
Use a concrete context type when the hook has context requirements. TContract
carries the agent's typed tools plus private/client-readable run and thread
metadata through hook inputs. A named AgentLifecycle object remains the preferred unit for policies
that should be composed and identified together.
Common Inputs
type LifecycleRun<TContract extends AgentContract = AgentContract> = Readonly<{
runId: RunId;
threadId: ThreadId;
/** Operation that created the stable logical run. */
initiator: "send" | "trigger" | "regenerate";
/** Logical-run start, stable across a waiting/resume boundary. */
startedAt: string;
/** Current execution claim or resume episode. */
execution: Readonly<{
operation: "send" | "trigger" | "regenerate" | "resume";
startedAt: string;
}>;
/** Owner-fenced server-only data scoped to this logical run. */
privateMetadata: LifecycleRunMetadataClient<
PrivateRunMetadataOf<TContract>
>;
/** Owner-fenced server-written data projected to ordinary clients. */
metadata: LifecycleRunMetadataClient<RunMetadataOf<TContract>>;
}>;
type LifecycleRunMetadataClient<TMetadata extends JsonObject = JsonObject> = {
get(): Promise<Readonly<TMetadata>>;
merge(patch: RunMetadataPatch<TMetadata>): Promise<Readonly<TMetadata>>;
};
type LifecycleStepInfo = {
stepId: StepId;
index: number;
};
type LifecycleBase<
TRuntimeContext = unknown,
TContract extends AgentContract = AgentContract,
> = {
/** Agent-wide runtime-only state returned by `runtimeContext`. */
runtimeContext: TRuntimeContext;
/** Stable run/thread identity for cache keys, diagnostics, and policy. */
run: LifecycleRun<TContract>;
/** Server-only query/mutation facade scoped to the current thread and run lease. */
thread: LifecycleThread<TContract>;
/** Abort signal for the run. */
signal: AbortSignal;
};
type LifecycleThread<TContract extends AgentContract = AgentContract> = {
readonly threadId: ThreadId;
messages: LifecycleThreadMessageClient<TContract>;
privateMetadata: LifecycleThreadMetadataClient<
PrivateThreadMetadataOf<TContract>
>;
metadata: LifecycleThreadMetadataClient<ThreadMetadataOf<TContract>>;
context: LifecycleThreadContextClient<TContract>;
mutate<TResult>(
fn: (
tx: LifecycleThreadTransaction<TContract>,
) => MaybePromise<TResult>,
options?: {
expectedVersion?: ThreadVersion;
expectedPrivateMetadata?: Readonly<PrivateThreadMetadataOf<TContract>>;
},
): Promise<TResult>;
};
type LifecycleThreadTransaction<
TContract extends AgentContract = AgentContract,
> = {
messages: LifecycleThreadMessageClient<TContract>;
privateMetadata: LifecycleThreadMetadataClient<
PrivateThreadMetadataOf<TContract>
>;
metadata: LifecycleThreadMetadataClient<ThreadMetadataOf<TContract>>;
context: LifecycleThreadContextClient<TContract>;
};
type LifecycleThreadMessageClient<
TContract extends AgentContract = AgentContract,
> = {
append(
input: AppendLifecycleMessageInput<TContract>,
): Promise<LifecycleMessage<TContract>>;
updateVisibility(
messageIds: MessageId[],
visibility: Partial<LifecycleMessageVisibility>,
): Promise<void>;
list(
query?: LifecycleMessageListQuery,
): Promise<Page<LifecycleMessage<TContract>>>;
get(messageId: MessageId): Promise<LifecycleMessage<TContract> | null>;
getMany(messageIds: MessageId[]): Promise<LifecycleMessage<TContract>[]>;
};
type LifecycleMessage<TContract extends AgentContract = AgentContract> =
PaiCanonicalMessage<TContract> & {
visibility: LifecycleMessageVisibility;
privateMetadata: PrivateMessageMetadataOf<TContract>;
};
type LifecycleMessageVisibility = Readonly<{
transcript: boolean;
context: boolean;
}>;
type AppendLifecycleMessageInput<
TContract extends AgentContract = AgentContract,
> = {
role: "system" | "user" | "assistant";
parts: LifecycleMessagePart[];
visibility?: Partial<LifecycleMessageVisibility>;
metadata?: MessageMetadataInput<MessageMetadataOf<TContract>>;
privateMetadata?: MessageMetadataInput<
PrivateMessageMetadataOf<TContract>
>;
};
type LifecycleThreadMetadataClient<
TMetadata extends ThreadMetadata = ThreadMetadata,
> = {
get(): Promise<Readonly<TMetadata>>;
merge(metadata: ThreadMetadataPatch<TMetadata>): Promise<void>;
replace(metadata: TMetadata): Promise<void>;
};
type LifecycleThreadContextClient<
TContract extends AgentContract = AgentContract,
> = {
current(
query?: LifecycleContextQuery,
): Promise<LifecycleContextProjection<TContract>>;
messages(
query?: LifecycleContextQuery,
): Promise<Page<LifecycleMessage<TContract>>>;
};
type LifecycleContextProjection<
TContract extends AgentContract = AgentContract,
> = {
messages: LifecycleMessage<TContract>[];
modelMessages: ModelMessage[];
nextPageToken?: PageToken;
};
type LifecycleMessageListQuery = {
view?: "context" | "transcript" | "all";
order?: "asc" | "desc";
beforeMessageId?: MessageId;
afterMessageId?: MessageId;
limit?: number;
pageToken?: PageToken;
};
type LifecycleContextQuery = {
beforeMessageId?: MessageId;
afterMessageId?: MessageId;
limit?: number;
pageToken?: PageToken;
};LifecycleMessage is the trusted durable-message boundary. It keeps the
native-first id, role, metadata, and parts layout seen in
PaiMessage, then adds server-only visibility and private metadata. It is not
the provider prompt type.
ModelMessage is the AI SDK provider-context boundary. It uses content
rather than UI-message parts and deliberately omits PAI run, visibility, and
private-metadata facts. thread.context.current() returns both projections
when trusted code needs to inspect durable messages and the corresponding model
input.
LifecycleMessagePart is the contract-neutral native AI SDK part union that
lifecycle code may author directly. Application and PAI-reserved data parts
have their own controlled write paths, and registered static tool parts are
created by native model/tool reduction.
Run and thread metadata clients are typed from their respective reusable raw
{ private, public } schema pairs. privateMetadata stays on the trusted
server; metadata is projected into ordinary client state after a successful
write. Each merge() is a shallow top-level merge. PAI validates the complete
merged object, not only the patch, before committing it.
These writes are fenced by the current execution lease. They remain available
through one execution episode and its lifecycle hooks, but not while the run is
waiting or after it is terminal. Trusted post-run enrichment uses the
runtime/admin thread.runs facade instead of retaining a lifecycle client.
thread is not the raw storage provider. It is a runtime-owned server facade
for the current thread. Reads are explicit, lazy, and paginated; mutate()
composes multiple supported writes into one fenced mutation. Lifecycle hooks
cannot load arbitrary threads, bypass scope, or call provider-specific storage
APIs through it.
Thread private metadata is part of the durable aggregate but is omitted from thread heads, list results, client snapshots, and realtime payloads. A private-only write does not publish a client-visible head event.
privateMetadata.merge() and metadata.merge() add or replace the supplied
strict-JSON keys in their respective run or thread bags.
Run metadata is separate from thread metadata. Both run metadata clients use
the current run owner and provider time, so stale execution episodes cannot
write after lease loss. Browser RunState deliberately omits
run.privateMetadata and includes only run.metadata.
mutate() stages writes and commits them atomically after its callback
returns. Reads inside the callback observe persisted state and do not include
writes staged earlier in that callback. Pass expectedVersion for a complete
root CAS. A long-running strategy can instead use expectedPrivateMetadata to
compare its cursor or policy state without rejecting unrelated status, usage,
or lease-heartbeat revisions.
For paginated reads, repeat the same view, order, and boundaries with each
opaque pageToken. The bounded page size may change. Tokens identify a
continuation position; they do not replace explicit query policy.
resolveModelContext
resolveModelContext chooses the durable/base messages supplied to one model
step. It is middleware rather than a patch accumulator:
const recentWindow: AgentLifecycle = {
async resolveModelContext(input, next) {
const context = await next();
return context.slice(-20);
},
};For composeLifecycle(a, b), a is the outer resolver and b is inner.
Calling next() more than once returns the same memoized downstream result.
Calling next(base) replaces the downstream leaf while still running every
inner middleware layer. Cursor-backed sources use this form so redactors and
other policies continue to apply after compaction. The default leaf collects
every durable message with visibility.context: true in chronological order,
preserving agent behavior when the hook is absent.
Each invocation receives history, an immutable physical upper anchor:
type LifecycleModelHistory<TContract extends AgentContract> = {
through: LifecycleMessage<TContract> | null;
collect(query?: {
view?: "context" | "transcript" | "all";
order?: "asc" | "desc";
afterMessageId?: MessageId;
}): Promise<LifecycleMessage<TContract>[]>;
before(query?: {
view?: "context" | "transcript" | "all";
order?: "asc" | "desc";
afterMessageId?: MessageId;
limit?: number;
pageToken?: PageToken;
}): Promise<Page<LifecycleMessage<TContract>>>;
modelMessages(query?: {
view?: "context" | "transcript" | "all";
order?: "asc" | "desc";
afterMessageId?: MessageId;
}): Promise<readonly ModelMessage[]>;
};history.collect() is the safe common path: it follows every provider page and
includes through when that newest physical record matches the requested view
and range. It defaults to context-visible records in chronological order.
history.before() is the lower-level pagination escape hatch. Its pages are
strictly below through, so callers using it directly must include the anchor
themselves when appropriate. Both methods remain capped at the step-start
anchor, so messages appended after the model step begins cannot leak into the
step. The active run lease prevents regeneration and administrative history
rewrites from overlapping resolution. A resolver must not change the visibility
of messages while it is selecting them, so its phase-specific thread facade
omits messages.updateVisibility() both directly and inside mutate(). Perform
such rewrites before another run begins or after the current resolution has
completed.
A strategy can call next() and transform the ordinary context, call
next(base) to supply a cursor-backed source to inner middleware, or bypass
composition and call history.modelMessages() directly. Use
history.collect() when policy needs the trusted durable
LifecycleMessage[], and raw history.before() only when it needs explicit
pagination. The runtime converts model context from the private native message
values; it never passes the public lifecycle projection back into the SDK.
Run-local model-only messages are appended after this hook returns.
Use context compaction for the built-in
cursor-backed summary strategy. It decides and commits compaction from the
durable physical base before calling downstream next(base) exactly once.
Inner retrieval, windowing, redaction, and policy resolvers therefore see a new
summary on the same step and remain free to add, remove, or reorder model-input
messages without affecting the already-determined physical cursor.
History rewrite hooks
HistoryRewrite identifies durable operations that can invalidate
metadata-backed cursors:
type HistoryRewrite =
| {
kind: "regenerate";
targetMessageId: MessageId;
retainedMessageIds: readonly MessageId[];
removedMessageIds: readonly MessageId[];
beforeVersion: ThreadVersion;
}
| {
kind: "visibility";
changes: readonly {
messageId: MessageId;
before: LifecycleMessageVisibility;
after: LifecycleMessageVisibility;
}[];
beforeVersion: ThreadVersion;
};prepareHistoryRewrite runs before either rewrite commits. Regeneration supplies
the target plus chronological retained and removed physical message ids;
visibility rewrites supply each changed record's complete before/after
visibility. Both variants include the current version and typed private/public
thread metadata. Return sparse metadata patches that must share the rewrite
transaction:
const lifecycle: AgentLifecycle = {
prepareHistoryRewrite({ rewrite }) {
if (rewrite.kind !== "regenerate") return;
return {
privateMetadata: reconcileCursor(rewrite.retainedMessageIds),
};
},
};Regeneration authorization may invoke prepareHistoryRewrite again after a
stale-version retry. An administratively locked visibility rewrite invokes it
once. Keep the hook deterministic and free of external side effects.
historyRewritten runs after commit with rewrite.afterVersion. It is a
best-effort fact hook for idempotent reconciliation outside PAI's store; its
failure cannot roll back the rewrite and is reported through onRuntimeError.
Its signal is post-commit and independent of request cancellation. Hooks are
serialized per thread and concurrency-bounded across threads; pending facts may
be coalesced under load, so delivery is deliberately not guaranteed.
Use prepareHistoryRewrite for PAI
thread-metadata invariants, and use an application outbox or coordinated store
when an external invariant requires stronger delivery or atomicity.
Retry uses regeneration and follows the same hooks. Lifecycle and trusted/admin message-visibility writes use the visibility variant. The built-in compactor clears its boundary stack and invalidates its usage anchor whenever context eligibility changes. The active run lease and private metadata comparison keep in-flight summaries from committing against obsolete state.
prepareRun
type PrepareRunInput<
TRuntimeContext = unknown,
TContract extends AgentContract = AgentContract,
> = LifecycleBase<TRuntimeContext, TContract> & {
/** Latest user message when the operation appended one. */
latestUserMessage?: Extract<LifecycleMessage<TContract>, { role: "user" }>;
/** Operation input that created or resumed this run. */
input: unknown;
};
type PrepareRunResult =
| {
/** Continue into model steps with optional defaults for this episode. */
action: "continue";
patch?: RunPatch;
}
| {
/**
* Commit this assistant response and complete the run without calling
* the model.
*/
action: "respond";
response: AssistantResponseInput;
outcome?: "completed" | "blocked";
reason?: string;
}
| {
/** Complete the run without committing an assistant message. */
action: "complete";
reason?: string;
}
| {
/** Fail the run without pretending the agent answered. */
action: "fail";
reason: string;
message?: string;
};
type RunPatch = {
/** Episode-local model default used by later model steps unless overridden. */
model?: LanguageModel;
/** Episode-local instruction default used by later model steps unless overridden. */
instructions?: Instructions;
/** Episode-local model-only context appended before the first model step. */
appendModelMessages?: ModelMessage[];
/** Episode-local tool manifest used by later model steps unless overridden. */
tools?: ToolManifestEntry[];
/** Complete provider-native tool map used by later model steps. */
providerTools?: ToolSet;
/** Episode-local tool choice used by later model steps unless overridden. */
toolChoice?: ToolChoice<ToolSet>;
/** Override the agent's model-step ceiling for this execution episode. */
maxSteps?: number;
/** Portable AI SDK generation settings used as episode-local defaults. */
modelSettings?: ModelSettings;
/** Episode-local provider options used as defaults by later model steps. */
providerOptions?: ModelProviderOptions;
};prepareRun runs after the runtime has accepted the operation and created a
run. It does not eagerly receive a complete thread snapshot. Use
latestUserMessage for the common current-input case, or query only the needed
history through thread.messages, thread.context, and thread.metadata.
It is not the main access-control boundary. Access checks should happen before a user message or trigger is accepted.
action: "respond" adds a real assistant message to the thread and completes the run. Use it for cache hits, quota messages, and policy responses that should be visible in the transcript.
action: "complete" completes the run without adding a message. Use it for hidden triggers or no-op background work.
Assistant responses produced by prepareRun still pass through canonical assistant-output policy before commit, so redaction and output policy cannot be bypassed by synthetic responses.
prepareModelStep
type PrepareModelStepInput<
TRuntimeContext = unknown,
TContract extends AgentContract = AgentContract,
> = LifecycleBase<TRuntimeContext, TContract> & {
step: LifecycleStepInfo;
/** Current model input after earlier lifecycle helpers have patched it. */
input: Readonly<ModelStepRequest<TContract, TRuntimeContext>>;
};
type PrepareModelStepResult =
| {
action: "continue";
patch?: ModelStepPatch;
}
| {
/**
* Commit this assistant response and complete the run without calling
* the model for this step.
*/
action: "respond";
response: AssistantResponseInput;
outcome?: "completed" | "blocked";
reason?: string;
}
| {
/** Stop the run as a structured policy block. */
action: "block";
reason: string;
response?: AssistantResponseInput;
};
type ModelStepPatch = {
/** Override the model for this step only. */
model?: LanguageModel;
/** Override system instructions for this step only. */
instructions?: Instructions;
/** Replace the model-input message list for this step only. */
messages?: ModelMessage[];
/** Append model-only messages for this step only. */
appendModelMessages?: ModelMessage[];
/** Replace the tool manifest exposed to the model for this step only. */
tools?: ToolManifestEntry[];
/** Replace the complete provider-native tool map for this step only. */
providerTools?: ToolSet;
/** Override tool choice for this step only. */
toolChoice?: ToolChoice<ToolSet>;
/** Portable AI SDK generation settings for this step only. */
modelSettings?: ModelSettings;
/** Provider-specific options for this step only. */
providerOptions?: ModelProviderOptions;
};prepareModelStep patches are ephemeral. The next model step starts from the
current execution episode's defaults, model-only overlay, and current thread
state, then lifecycle helpers run again.
respond and block skip aroundModelCall and tool phases. If they include an assistant response, that response still goes through canonical assistant-output policy before commit.
Model configuration is applied in this order:
agent ModelSelection
-> prepareRun patch
-> prepareModelStep patches, left to right
-> aroundModelCall patches, outside to insideLater modelSettings merge field by field. Later providerOptions merge
shallowly by provider key; replacing one provider entry does not recursively
merge that provider's nested object. providerTools and toolChoice replace
as whole values rather than merging. Instruction values also replace as a
whole; arrays of system messages are not merged across lifecycle layers.
prepareRun supplies an episode-level instruction default.
prepareModelStep and aroundModelCall replace instructions for the current
PAI model step only; unlike the AI SDK's own internal prepareStep, those
replacements do not carry into the next PAI step.
A sparse prepareRun or prepareModelStep patch supplying model starts a
fresh route, even if it supplies the same model object. It clears inherited
modelSettings, providerOptions, providerTools, and toolChoice, then
applies replacements supplied by that patch. Registered PAI tools remain
inherited unless explicitly replaced. This prevents provider-specific state
from leaking into another model route.
aroundModelCall
type ModelCallNext = {
(): Promise<ModelStepStreamResult>;
(patch: ModelCallPatch): Promise<ModelStepStreamResult>;
};
type ModelCallPatch = ModelStepPatch & {
/** Additional attempt signal, combined with the runtime signal. */
signal?: AbortSignal;
};
type ModelStepRequest<
TContract extends AgentContract = AgentContract,
TRuntimeContext = unknown,
> = {
runId: RunId;
threadId: ThreadId;
stepIndex: number;
model: LanguageModel;
instructions: Instructions;
messages: ModelMessage[];
tools: ToolManifestEntry[];
providerTools?: ToolSet;
toolChoice?: ToolChoice<ToolSet>;
modelSettings?: ModelSettings | undefined;
providerOptions?: ModelProviderOptions | undefined;
signal: AbortSignal;
runtimeContext: TRuntimeContext;
};
type ModelStepResult = {
text?: string;
toolCalls?: Array<{
id?: string;
toolName: string;
input: unknown;
providerExecuted?: boolean;
providerMetadata?: ProviderMetadata;
}>;
finishReason?: FinishReason;
providerMetadata?: ProviderMetadata;
usage?: UsageSummary;
};
type ModelStepStreamResult = {
stream: ReadableStream<StepStreamChunk>;
};
/** Text arms of StepStreamChunk. */
type TextStepStreamChunk =
| {
type: "text-start";
id: string;
providerMetadata?: ProviderMetadata;
}
| {
type: "text-delta";
id: string;
text: string;
providerMetadata?: ProviderMetadata;
}
| {
type: "text-end";
id: string;
providerMetadata?: ProviderMetadata;
};ModelStepRequest.providerOptions is outgoing provider configuration.
ModelStepResult.providerMetadata, tool-call providerMetadata, and the
metadata fields on tool-input start/end, tool-call, tool-result, and tool-error
stream chunks are facts returned by the provider. Native tool parts keep call
and result directions separate as callProviderMetadata and
resultProviderMetadata; model replay maps each one to the corresponding
outgoing part's providerOptions.
Immediate StepStreamChunk fields mirror the AI SDK's high-level stream:
text retains explicit start/delta/end boundaries and their block id, and
reasoning deltas carry their fragment as text, while tool-input-delta
chunks carry it as delta. Text IDs are required on all three boundaries,
including text synthesized by lifecycle responses. PAI reduces the canonical
stream into native UI-message parts and emits native UIMessageChunk values at
the private transport boundary. Public clients observe the resulting
PaiMessage through ThreadState.messages, including retained provider
metadata; there is no second application event or part grammar.
aroundModelCall is the narrow replacement for generic model-call middleware.
Lifecycle hooks receive the complete model request as a shallow read-only
view. Its runtimeContext field is inferred from the agent's runtime-context
builder. Route
changes go through the hook's sparse patch result or next(patch) instead of
reassigning request fields. Nested application and provider objects are not
recursively read-only. This includes structured instruction arrays: treat them
as observational inputs and return a replacement patch instead of mutating
them in place.
const fallback: AgentLifecycle<MyContext> = {
aroundModelCall(input, next) {
if (modelHealth.isUnavailable(input.model)) {
return next({ model: fallbackModel });
}
return next();
},
};aroundModelCall is for last-mile model-call semantics: conditional routing,
model-call caching, attempt-scoped signals, and stream wrapping. It returns a
stream result because streaming is the primary execution path. Use
prepareModelStep for ordinary prompt, tool, or model-context changes.
Each wrapper receives the complete effective request after outer wrappers have
patched it. Calling next() preserves that request. Calling next(patch)
applies a sparse patch, and a patch containing model clears inherited
modelSettings, providerOptions, providerTools, and toolChoice before
applying values from that patch. Registered PAI tools remain inherited. The
patch rejects runtime-owned request fields, so do not pass or spread input
into next().
Immediately before each provider attempt, after all lifecycle patches, PAI
removes assistant reasoning and reasoning-file parts before the latest
user message in the effective request. The entire current user turn retains
its reasoning across tool steps, suspension, and resumption. This is a
user-turn boundary, not a model-step or run boundary. If the request contains
no user message, PAI keeps reasoning because it cannot identify a completed
turn safely.
The filter uses the SDK's pruneMessages on the historical prefix, additionally
filters reasoning files, and removes empty messages there. Other content and
provider metadata on retained parts are preserved. History queries and
lifecycle hooks still receive unpruned context; the actual attempt request
reported after the call reflects the filtered prompt. Stored messages,
client-visible reasoning, and usage totals are not rewritten.
For an attempt timeout, supply an additional signal. PAI combines it with the runtime-owned signal so stop and cancellation still reach the provider:
return next({
signal: AbortSignal.timeout(10_000),
});next() resolves after constructing the provider stream, before that stream is
fully consumed. A try/catch around await next() catches setup failures but
not ordinary errors delivered later by the stream. Stream-time retry or
fallback needs explicit stream handling and is only safe before any canonical
output chunk has been emitted. A strict helper can buffer until it knows a
retry is still safe.
transformStepStream
type StepAbortInput = {
/** Machine-readable policy or failure reason. */
reason: string;
/** Defaults to "blocked" for policy aborts. */
outcome?: "blocked" | "failed";
/** Optional diagnostic message. Not necessarily model-visible. */
message?: string;
/** Optional safe assistant response committed instead of the partial step. */
replacement?: AssistantResponseInput;
};
type TransformStepStreamInput<
TRuntimeContext = unknown,
TContract extends AgentContract = AgentContract,
> = LifecycleBase<TRuntimeContext, TContract> & {
step: LifecycleStepInfo;
stream: ReadableStream<StepStreamChunk>;
input: Readonly<ModelStepRequest<TContract, TRuntimeContext>>;
/** Capability-narrowed native application data writer. */
uiData: PaiApplicationDataWriter<TContract>;
/**
* Controlled policy abort. Cancels the upstream provider stream and prevents
* the current partial step from being committed unless a replacement is
* supplied.
*/
abort(input: StepAbortInput): Promise<never>;
};
type PaiApplicationDataWriter<TContract extends AgentContract> = {
write(chunk: PaiApplicationDataChunk<TContract>): void;
};The returned stream is canonical. Use it for transformations that must survive refresh.
uiData.write() is the only application data authoring path. Its chunk union
is inferred from uiDataSchemas and the AI SDK's native UIMessageChunk
specialization, so it cannot emit metadata, tools, sources, or framework data.
PAI parses the registered schema, requires the parsed strict-JSON value to be a
schema fixed point, and then forwards the canonical chunk through the same
native reducer and live publisher as the model stream. This validation also
applies to transient data because transient chunks never reach a stored
snapshot.
Ordinary model-produced tool calls have already been parsed by the AI SDK. PAI
keeps an unexported, non-serialized proof for those parsed values, so passing a
call through or cloning it with object spread without changing its
schema-relevant fields does not run a transforming schema twice. The proof is
bound to the exact schema authority used for that attempt. A tool-call
authored by the lifecycle hook, one rebuilt without that proof, or one whose
toolName, providerExecuted, or JSON-compatible input changed is treated
as raw schema input and parsed once after the complete transform composition,
before it can be persisted, reach PAI-owned application execution, or become a
pending client action. Invalid input becomes the existing model-visible
tool-input error and does not reach beforeToolCall. A provider-executed tool
may already have run remotely before its call and result chunks reach PAI;
post-stream validation can reject that result but cannot undo the provider-side
effect. Non-JSON invalid input is omitted from the durable error record instead
of making persistence fail. Once a call is validated, a later matching
tool-result or tool-error cannot replace its canonical input; a result
following an invalid call is ignored so it cannot turn that error into a
successful tool part.
The effective request's tools list is also authoritative for execution. A
registered tool omitted by a run, step, or model-call patch is unavailable for
that attempt even if a provider emits its name anyway. PAI snapshots the tool
list when next() begins so the provider, lifecycle observers, telemetry, and
executor see the same request; mutating a reused tools array afterward cannot
expand execution authority. If a wrapper makes
more than one successful next() call and returns a newly wrapped stream whose
source request cannot be identified, PAI fails closed: a registered tool stays
enabled only when every successful attempt advertised it.
The same canonical output policy is applied to assistant responses produced by prepareRun or prepareModelStep. Implementations can represent those responses as synthetic step streams so redaction and block helpers do not need a separate code path.
abort() is for controlled policy interruption, such as streamed output guardrails. Throwing from a hook means runtime failure. abort() means the agent deliberately stopped the step with a structured reason.
Already-emitted live chunks cannot be unsent. Strict guardrails should buffer until content is considered safe, then release it.
Tool Hooks
type ToolInfo = {
name: string;
source: "backend" | "client" | "mcp" | "subagent";
description?: string;
};
type ToolCallInfo = {
toolCallId: string;
};
type ToolExecutionEntry =
| { type: "initial" }
| {
type: "resume";
name: string;
resume: unknown;
action: PendingActionRef;
};
type BeforeToolCallInput<
TRuntimeContext = unknown,
TContract extends AgentContract = AgentContract,
> = LifecycleBase<TRuntimeContext, TContract> & {
step: LifecycleStepInfo;
tool: ToolInfo;
call: ToolCallInfo;
entry: ToolExecutionEntry;
suspendHistory: unknown[];
input: unknown;
};
type BeforeToolCallResult =
| {
/** Continue execution or routing unchanged. */
action: "continue";
}
| {
/** Continue execution or routing with replaced tool input. */
action: "replaceInput";
input: unknown;
}
| {
/** Skip execution/routing and use this synthetic tool output. */
action: "skip";
output: unknown;
}
| {
/** Reject the tool call with a model-visible tool error. */
action: "reject";
message: string;
};
type ToolResult = {
status: "output-available" | "output-error";
output?: unknown;
error?: unknown;
message?: string;
reason?: string;
source?:
| "execute"
| "skip"
| "resume"
| "client"
| "manual"
| "reject"
| "throw"
| "fail"
| "cancel"
| "recovery";
};
type AfterToolResultInput<
TRuntimeContext = unknown,
TContract extends AgentContract = AgentContract,
> = LifecycleBase<TRuntimeContext, TContract> & {
step: LifecycleStepInfo;
tool: ToolInfo;
call: ToolCallInfo;
entry: ToolExecutionEntry;
suspendHistory: unknown[];
input: unknown;
result: ToolResult;
};
type AfterToolResultResult =
| {
action: "continue";
}
| {
/** Replace successful tool output before the model sees it. */
action: "replaceOutput";
output: unknown;
}
| {
/** Replace the model-visible error/rejection message. */
action: "replaceMessage";
message: string;
};beforeToolCall runs after the model emits a validated tool call and before that call is executed, routed to a client, or turned into a pending action.
afterToolResult runs for any final tool result before the model sees it, including backend results, skipped outputs, rejected calls, resumed tools, client-submitted results, manually fulfilled results, and recovery errors. A beforeToolCall skip skips execution only; the synthetic output is still validated and passed through afterToolResult.
For backend failures, source: "throw" identifies an exception-based failure,
while source: "fail" means the tool deliberately returned
ctx.fail(failure).
When a tool suspends, there is no final model-visible result yet, so afterToolResult does not run until the tool resumes and reaches output-available or output-error.
Human approval and data collection belong in tool suspend() definitions, not lifecycle hooks.
afterStep
type AfterStepInput<
TRuntimeContext = unknown,
TContract extends AgentContract = AgentContract,
> = LifecycleBase<TRuntimeContext, TContract> & {
step: {
stepId: StepId;
index: number;
message: LifecycleMessage<TContract>;
finishReason: string;
providerMetadata?: ProviderMetadata;
usage?: UsageSummary;
toolCalls: unknown[];
};
/** Effective request used by the completed provider call. */
request: Readonly<ModelStepRequest<TContract, TRuntimeContext>>;
};
type NextStepPatch =
| {
/** Append execution-local model-only messages for the next step. */
appendModelMessages: ModelMessage[];
}
| {
/** Replace execution-local model context for the next step. */
replaceModelMessages: ModelMessage[];
};
type AfterStepResult =
| {
/** Request another model step after mandatory waiting and steering checks. */
action: "nextStep";
patch?: NextStepPatch;
}
| {
/** Complete the run even if the model requested another step. */
action: "complete";
};Returning undefined leaves the next transition to the runtime: a step with
runtime tool calls continues naturally, while a step without them completes.
Return nextStep only to request another model step regardless of that natural
completion rule. Waiting and steering take precedence over nextStep.
Return complete to finish deliberately. If that step contains waiting tools,
the runtime atomically cancels those pending tool parts before completing the
run, so it never leaves an open action on a terminal run.
afterStep cannot rewrite the assistant message that was just assembled. Use transformStepStream if the committed message needs to change.
appendModelMessages and replaceModelMessages update the model-only overlay
for subsequent steps in the current uninterrupted execution episode. The
overlay is discarded if waiting/resume, steering, completion, cancellation, or
loss of execution ownership intervenes. It is not transcript state.
Use hidden lifecycle context messages for model context that must survive resume. Persist durable application or reconstruction state in typed run metadata, your application database, or tool side effects.
Lifecycle hooks should not patch provider records directly. The runtime is responsible for applying hook results to messages, tool parts, execution-local model context, and run status. This keeps hook behavior portable across storage providers and prevents a hook from corrupting the tail-message or active-run lease invariants.
composeLifecycle
function composeLifecycle<
TRuntimeContext,
TContract extends AgentContract = AgentContract,
>(
...parts: AgentLifecycle<TRuntimeContext, TContract>[]
): AgentLifecycle<TRuntimeContext, TContract>;Composition is deterministic:
- non-around hooks run left to right;
- value-transforming phases pass the patched input, stream, tool input, or tool
result to later hooks;
prepareRunandafterStepinstead accumulate child decisions against the same input; aroundModelCallcomposes as wrappers, with the first lifecycle part outermost;- the first
prepareRunorprepareModelStepthat returnsrespond,block,complete, orfailends the relevant run path; - assistant responses from lifecycle hooks still pass through canonical assistant-output policy before commit;
- the first
beforeToolCallthat returnsskiporrejectskips tool execution/routing for that call; - skipped tool outputs and submitted tool results still run validation and
afterToolResult; - thrown errors fail the run.
aroundModelCallmay catch setup failures from the call it wraps; failures delivered during stream consumption require stream-aware handling.
const lifecycle = composeLifecycle(
withRag(),
redactSecrets(),
conditionalModelRoute(),
);Use Lifecycle Hooks for examples.