Pre-LLM request hooks
Transform a normal conversational provider request with a workspace lambda
On this page
pre_llm_hook optionally runs a workspace lambda immediately before each normal conversational provider request. It can rewrite the request-local system prompt, messages, provider-facing tool definitions, and model request options.
Triggers and hooks are different. Triggers react to events and start work. Hooks run inside work already underway; the pre-LLM hook transforms the request before the model call continues.
It is for bounded transformations such as adding a policy reminder, selecting a smaller presented tool set, adapting a provider option, or running a fast RAG lambda with the existing adf.* capabilities. It is deliberately a small interception point, not a separate built-in RAG or tool-selection subsystem, and it is never a way to grant capabilities.
Configuration
{
"pre_llm_hook": {
"source": "lib/request-policy.ts:transform",
"scope": "all",
"timeout_ms": 5000
}
}
sourceis a workspace.jsor.tslambda path, optionally followed by:functionName; omitted function name meansmain.scopeisall(main and every current/future inner loop),main, orloops. Existing configurations keep this shape.loopsis required only forscope: "loops"; it is a non-empty unique list of named inner loops. It cannot containmain.include_mainis optional and valid only withscope: "loops"; when true, the hook also runs for Main. This additive field lets Studio represent Main plus specific inner loops without puttingmaininto the inner-loop list.timeout_msis optional. It is always capped bylimits.execution_timeout_ms.
Target named inner loops without requiring that they already exist:
{
"pre_llm_hook": {
"source": "lib/request-policy.ts:transform",
"scope": "loops",
"loops": ["researcher", "reviewer"]
}
}
Studio presents targets as rows: a new hook starts with Main, and Add target adds a declared or future inner-loop name. The All streams (including future loops) option is exclusive with specific target rows and remains an explicit scope: "all" configuration; opening an existing all hook never narrows it to a finite list. Main plus named loops is stored as scope: "loops", include_main: true, and a loops array.
The hook is an explicit runtime lambda entry point. It does not require—or expose—sys_lambda as a conversational tool. Its private execution bridge may call nested adf.sys_lambda only when the independent code_execution.sys_lambda gate and normal source-authorization rules allow it. That private backend does not register sys_lambda for ordinary LLM calls or for unrelated sys_code executions; those remain declaration- and enabled-state dependent.
Contract
The lambda receives one JSON-safe object:
async function transform({ request, loop }) {
// loop.name is "main" or the current inner-loop name
return {
system: request.system,
messages: request.messages,
tools: request.tools,
options: request.options,
}
}
request.options may contain:
maxTokens,temperature,topP,thinkingBudgetreasoningdynamicInstructionsproviderParams
The lambda must return a complete replacement request object—not the outer { request, loop } object. Returned values must be JSON, use valid conversational message/tool shapes, and preserve valid tool-use/tool-result pairing.
Example: present just two existing tool schemas on ordinary conversational calls.
export function transform({ request }) {
return {
...request,
tools: request.tools.filter((tool) =>
tool.name === 'fs_read' || tool.name === 'fs_write'
),
}
}
Fast RAG with an existing lambda
A hook can call an existing retrieval lambda and return its enriched messages
with the rest of the original request unchanged. This is a normal adf.* call:
the nested sys_lambda follows its own file authorization and execution gates.
For example, lib/fast-rag.ts:enrichMessages can retrieve from an approved
index and return { messages: LLMMessage[] }.
export async function transform({ request, loop }) {
const enrichment = await adf.sys_lambda({
source: 'lib/fast-rag.ts:enrichMessages',
args: {
messages: request.messages,
loop: loop.name,
},
})
return {
...request,
messages: enrichment.messages,
}
}
The retrieval lambda must return valid conversational messages. The hook still returns the full replacement request, and a retrieval failure fails that call closed rather than sending the un-enriched original request. Hook workers are fresh request-scoped sandbox workers, so do not rely on module/global state persisting between tool rounds.
Boundaries and failures
- The hook runs after context repair and immediately before each normal conversational call, including later calls after tool results.
- It does not run for
adf.model_invoke()or automatic history compaction. Callingadf.model_invoke()inside a hook uses that direct path and does not recurse. - Abort signal and streaming callbacks stay runtime-owned; a hook cannot replace them.
- Each hook invocation runs in a fresh, isolated worker and is destroyed on completion, failure, or abort. Worker termination is the supported prompt cancellation mechanism; because the VM has no per-execution interrupt, termination would cancel other executions sharing that worker, which is why hooks do not share workers with ordinary callers.
- A malformed result, missing source, execution error, timeout, or cancellation fails the current call closed. The original request is never silently dispatched. Hook failures are recorded as a local structural
[Turn error]in the loop and do not enter provider-auth, transient-provider, image, or tool-mismatch recovery. When the established bounded structural recovery is enabled, a later retry reruns the hook; retry notices are labeledTurn error, not as provider failures. An already-dispatched host-sideadf.*RPC cannot be retracted by abort; worker termination prevents the sandbox continuation and late writes, but the host handler may finish independently. - Hook code gets a private
adf.*RPC bridge with the same live loop-specific restrictions, HIL rules, and source-file authorization semantics as other lambdas. Its nestedsys_lambdabackend is hook-local and independently gated bycode_execution.sys_lambda; ordinary shared tool registration is unchanged. Inner-loop hooks use their loop’s attenuated handler. - Changing
request.toolschanges only what the provider is shown. Actual tool execution still uses the loop’s original enabled-tool snapshot, validation, HIL, and file/protection checks. A hook cannot grant a tool or bypass authorization.
See code execution and authorized code for lambda capabilities and file authorization.