Reference for your agent. The precise shape of things — signatures, fields,
rules — written so an agent can read it and act. You do not need to: ask your
agent for what you want in words, and if it needs this page, hand it the URL or
use Copy page. What to ask for, and how to check it, is in Ask;
how to see what changed is in Look inside.
hooks/ as
one file per point.
programs/splox/hooks/stop.py holding done is the hook
stop.done.
Hooks are how you shape a run without touching the agent. The agent says who it
is; the hooks say what happens around it, every turn, for every agent of that
program.
The ten points
The first argument is alwayst, the snapshot. The rest arrive as keyword
arguments, so their names are part of the contract — rename msg and the call
fails as a TypeError, which is a hook that did not answer.
The order within one iteration
Nine of them run in this order:guard.on_input on the message that just
arrived, memory.keep over the window, context.build and model.choose for the
request, the model call itself with errors.on standing next to a failure,
stop.done on the reply, then either tools.before and tools.after around each
call the model asked for, or guard.on_output on the answer if it is one.
memory.summarize is the tenth and sits outside that order: it is asked once per
compression, not once per turn.
context.build — what the model is given
This is the point that decides what an agent’s prompt actually is on a given turn. The agent’s ownsystem_prompt= arrives as t.system; everything printed
around it — the tool catalog, the skills, the user’s notes, the sections about
spawning sub-agents — is this file’s doing, and deletable by deleting it.
The default is a request of six keys — model, messages, response_mode,
tool_choice, tools, generation. A hook returning an object with messages
in it has replaced that request outright. Any other object is read as an edit
of the one the loop would have built: each key it names replaces that key and
nothing else, with system spelled for the part of a request that is not a field
of it, the window’s system message.
That is the common case, and the reason the form exists. The starter’s
hooks/context.py builds the whole prompt out of what it can read on disk and
ends with one line:
programs/splox/hooks/context.py:
t: t.spawned, t.skills and
t.output_schema.
model.choose — which model this turn runs on
It names the model and only the model. It runs aftercontext.build, so it
overrides a model that a context named, and it is only consulted when the hook
actually returns a name.
guard.on_input and guard.on_output — the two ends of a turn
Both take the text and may return it rewritten, ort.Reject(why), which ends
the run there with why as what the person reads.
guard.on_input is asked only when the newest thing in the window is a user
message — after a tool turn the newest thing is a result, and re-guarding an
answered message would rewrite it twice. Returning a non-empty string rewrites
the message in place; attachments stay where they are, since a guard that
rephrases a question must not drop the screenshot it came with.
guard.on_output has the same two powers over the answer, with one asymmetry: it
stands at the end of the turn, so a rewrite changes what is persisted and
returned, not what already went out on the live stream.
The starter’s guard is a good example of a hook that adds information rather than
refusing anything — it appends a note to the user’s message once the window is
genuinely large:
tools.before and tools.after — around every call
before is handed call as {"id", "name", "args"} and may return it edited —
a new name, new args — or t.Deny(reason), which stops the call and hands
the reason to the model in place of a result, so the model can choose something
else.
/dev/null is not writing to a
disk, and a rule that cannot tell the two apart gets turned off within the hour.
A refusal comes back to the model as the reason, which is the difference between
a guard rail and a dead run.
The platform’s own gates have already had their say by this point: a Deny can
stop a call that was allowed, and nothing here can start one that was refused.
Renaming is bounded by the tools the agent actually has; a name it was not given
comes back as an error result.
after is handed result as {"id", "name", "content", "is_error"}, where
content is what the platform already rendered. Return other text to replace it;
anything that is not text leaves the rendering alone.
stop.done — whether the run is over
There is no iteration counter left on the platform. The loop takes another pass for as long as this file says to, and a program with nohooks/stop.py gets the
SDK’s own rule instead: keep going until the agent’s declared max_iterations are
used.
Five things can be said here, and each one is a sentence:
A turn count is a bad place to stop. It knows how many passes have happened and
nothing about whether the work is finished, so the run it cuts short is the long
one — the build that was still compiling, the page that was still being fixed —
and it cuts it at the same number every time, which is to say at random with
respect to the job. Prefer a condition that knows what the run was for:
t.max_iterations and t.tool_calls reach this point and nothing else reads
them: they are the whole input of the stopping rule, and they travel in the
snapshot so a program deciding how long to keep going never has to ask the
platform for the count first.
errors.on — a model call that failed
Asked once about a failed model call, witherr as text.
t.Retry(seconds)waits and tries the same call again, capped at 30 seconds. A retry with no wait is still a retry.t.Switch(provider, model)moves the run onto another provider for the rest of the run, and needs both halves: model identifiers belong to the provider that serves them, so the platform will not guess one for you. It is resolved before it is recorded, against the providers and models this deployment actually has, and a name that resolves to nothing leaves the run running on the platform’s own error rule.t.Escalate()ends the turn without any repair attempt.
memory.keep and memory.summarize
memory.keep is the odd one. Its return value is not read: answering anything
that is not a deferral means the hook has taken the job, so the platform’s own
three cuts — the image budget, compaction, the oversized-message projection — do
not run and the window is sent as it stands. The editing itself goes through
t.history.
memory.summarize is asked once per compression, before any of the history is
sent, and it answers the prompt the summarizer runs on, as text. prompt is what
the platform would have said, handed over so the file can add a rule to it rather
than restate one.
memory.keep.
Giving the point back
t.USE_DEFAULT is the answer of a hook that decided, halfway through its own
logic, that the platform should answer after all. t.default.done(reply) is the
same value with the point’s name on it — arguments accepted and dropped — so a
hook reads like a hook instead of like a protocol.
Either way the default runs on the platform’s side, the run behaves exactly as it
would have without the file, and the trace records that the hook deferred
rather than that it was absent.
A verdict belongs to its point:
A verdict at the wrong point is recorded as malformed and the default runs, which
is the same outcome as returning something unreadable — a bare object where text
was expected, a non-JSON value, an empty rewrite.
What t carries
Everything on t other than the callbacks travelled inside the request that
called the hook, so reading it is free.
int
Which pass of the loop this is.
int
The prompt token count the provider last reported.
int
The token budget this conversation is measured against.
str
The model this turn is about to run on.
str
The agent’s name.
str
The commit this run is executing.
str
This run.
str
This harness.
str
The message that just arrived.
str
The system prompt this turn is about to send, whole.
str
The project’s own instructions, if the chat belongs to one.
The shape this run’s answer has to match, if it was given one.
list
The skills this agent listed, each a
name and a description. What a context hook renders a skills section from.bool
Whether this run was started by another run.
int
How many calls the model asked for on the pass just taken. Put there by
stop.done alone; 0 everywhere else.int
The passes the agent was declared with. Read by
stop.done and nothing else.t.spawned is the one worth reading in context.build. The whole conversation
shares one sandbox, so program() answers a sub-agent too; telling it in the
prompt how to reach an agent is what makes a child fan out again, and the
starter’s hooks/context.py withholds that section from a spawned run for
exactly that reason.
t is an ordinary object built fresh for each call, so a hook may write to it
before handing it on — which is how a helper in the same file can be given a
snapshot that says something slightly different from the one that arrived.
The callbacks
Three things ont leave the sandbox, as ordinary HTTP requests to the API
carrying the credential the sandbox already holds. They cost a round trip, so
they are asked for and not sent by default.
t.llm
model= and nothing else. The answer is the
text, with the token count on it as .tokens.
t.store
None, which is the first run of every hook that keeps a note. A value may be at
most 256 KB and a harness at most 1000 keys; over either limit the write is
refused and the hook is told which one, because a value that came back trimmed is
a note that lies.
t.history
The run’s own conversation, keyed by tag — a tag is the message row’s id.Two caveats on this version, both real: the tail that travels with the snapshot
does not reach
t.history, so t.history.tags is empty and every tail() is a
round trip; and t.history.collapse sends a field name the endpoint does not
accept, so it fails rather than folding anything.t.tool
When a hook does not answer
A hook that raises, times out, cannot be reached, or answers something the point cannot read is a hook that did not answer — and the platform will not answer in your name. The point is answered by the last answer that point itself gave, replayed out of storage and said out loud on the run as ahook.fallback carrying the point,
what the hook did instead of answering, and how old the replayed answer is. Where
that point has never answered — a hook that has been broken since it was written
— the run ends:
Timeouts
The loop gives a hook 30 seconds and the server in the sandbox is what enforces it, the same split as a tool call: a deadline on the platform’s side would abandon the connection instead of interrupting the work. A hook that overruns comes back as an error from a server that is still alive, the default runs, and the trace says the hook failed. The overrunning thread is not killed — it finishes into nothing — so a hook that hangs on a socket every turn costs the run 30 seconds a turn and leaves a thread behind each time. Thirty is generous because a hook is allowed to call the model throught.llm,
and mean because a hook stands between the user and every turn. One t.llm call
has its own 30-second budget, which means a single slow one can consume the
hook’s entire allowance.
One place per point, and no importing a neighbor
A point has exactly one file that can answer it:programs/<program>/hooks/, for
the program the run belongs to. A run of another program is answered by that
program’s files, and a sub-agent is answered by its parent’s, because a sub-agent
is the same program still working.
Hooks are imported off sys.path, each file on its own under a private module
name, and the directory deliberately never joins the path: hooks/tools.py is one
of the contract’s file names, and a directory holding it on the path would answer
import tools — the projected system tools every tool and wrapper reaches for —
with a hook. So there is no importing a neighbor by name. Helpers are private
functions in the same file.
The projected system tools are reachable, but only after a hook puts their
directory on the path itself, which is what hooks/guard.py above does before
from tools.memory import memory_window, treating the import failing as a reason
to defer rather than as an error.
Seeing whether any of this ran
A hook that works, a hook that works and agrees with the platform, and a hook being replayed from a week-old answer produce exactly the same conversation.harness_hook_trace is what tells them apart.
Only
use_default and malformed actually run the platform’s own function. A
failed is a point that was answered by its own last good answer, or one that
ended the run.
When to reach for one
- The prompt is missing something every turn —
context.build. It is the right place for anything that is true of the whole program rather than of one agent. - A tool call must not happen —
tools.before. Deny with a reason the model can act on. - A tool result is unreadable or enormous —
tools.after. - The run should keep going past where it stops, or stop before it does —
stop.done, on a condition that knows what the run was for. - One provider fails in a way you know how to handle —
errors.on. - The first turns deserve a different model —
model.choose. - A summary keeps losing the same thing —
memory.summarize.

