PAIPAI

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 inside

Later 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; prepareRun and afterStep instead accumulate child decisions against the same input;
  • aroundModelCall composes as wrappers, with the first lifecycle part outermost;
  • the first prepareRun or prepareModelStep that returns respond, block, complete, or fail ends the relevant run path;
  • assistant responses from lifecycle hooks still pass through canonical assistant-output policy before commit;
  • the first beforeToolCall that returns skip or reject skips tool execution/routing for that call;
  • skipped tool outputs and submitted tool results still run validation and afterToolResult;
  • thrown errors fail the run. aroundModelCall may 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.

On this page