Take it for a spin¶
Half an hour, ending with an agent that takes bookings and payments at a guesthouse you are also running -- and that stops and waits for a manager before it charges anybody.
The guesthouse is Hollowbrook House — a fixture with a REST API, deployed beside outturn. It stands in for the system a real deployment would be wiring agents up to, and going through it exercises the parts that matter: a skill describing an API, an egress rule permitting a host, a sandbox that holds no credentials, and a gateway that does.
What you need¶
A Mac with enough memory for a 20GB model, a local Kubernetes cluster, and ollama. Getting started covers the prerequisites and how to get a cluster.
1. Start it¶
That pulls the model if it is missing, brings up outturn and Hollowbrook, opens Hollowbrook's host on the gateway's operator allowlist, and runs a Job that installs a skill describing its API and an agent that uses it.
In another terminal:
cd ui && VITE_THEME=hollowbrook VITE_BRAND_NAME='Hollowbrook House' \
VITE_BRAND_LOGO=/hollowbrook-logo.svg npm run dev
Those three are why the next half hour looks like a guesthouse's own software
rather than like outturn. Vite reads them at build time, so they are set on the
command that starts it rather than configured in the app -- which is also why
--with hollowbrook cannot set them for you: the UI runs on the host and the
component installs into the cluster. ui/src/themes/README.md has the rest.
Plain npm run dev gives you outturn's own look, which is worth seeing once:
anything that survives both is genuinely coming from a token, which is what the
second theme exists to prove.
Open http://localhost:3000 and sign in as admin@outturn.local. The password
is this clone's own — scripts/dev-secrets.sh --print shows it.
2. Meet the agent¶
The same Job made one: Front desk, under Agents, with this system prompt and the Hollowbrook House skill already enabled:
You help the staff of Hollowbrook House, a guesthouse, with bookings and
payments. Whoever you are talking to works at the house and usually has a
guest on the phone or in front of them, so answer them as a colleague would:
briefly, and with what they need to say next. Keep to the house's business.
Notice what is not in it: nothing about HTTP, nothing about hostnames, nothing about how to call anything. That is the skill's job, and the point of the exercise is that the prompt does not have to know.
Notice who it says the user is, too. This platform has seats for the people who run a deployment and for the employees of its tenants, and deliberately none for a tenant's own customers -- so an agent here works for a member of staff, on the house's systems, and the guest is what the conversation is about rather than who is in it. A prompt written as though the guest were typing describes somebody this platform has nowhere to put, and the conversation reads oddly ever after.
The prompt is yours to edit from here: the Job made the agent once and leaves
it alone on every redeploy after. It lives in
k8s/components/hollowbrook/system-prompt.txt if you want to change what a
fresh cluster starts with.
3. The skill it was given¶
On the agent's page, under Skills, Hollowbrook House is enabled.
The skill was installed by the Job in step 1, along with an egress rule
permitting outturn-hollowbrook:8084. Without that rule the agent would be
refused at the gateway however well it understood the API — the rule is the
permission and the skill is only the knowledge. Any other agent you make in
this workspace can have the skill by enabling it there; the Job only bound it
to the one it made.
4. Talk to it¶
Sessions → start one with Front desk. Then, in order:
"What rooms do you have?"¶
Watch the tool calls. It loads read_object, reads
workspace/api/hollowbrook/list_rooms.md, loads fetch_url, and only then
calls the API.
That sequence is the whole design of the OpenAPI wizard. The skill in the prompt is a manifest — five operations, a sentence each, and where to read the rest — because a skill body is paid for on every round of every turn. The detail is a file the agent reads only when it decides it needs that operation.
It should quote prices in pounds. The API returns pence.
"Anything free for two people, arriving Friday, for two nights?"¶
A second operation, so a second detail file. Watch whether it reads that one too rather than assuming it can pattern-match from the first.
"Friday" is ambiguous, and it has no clock of its own. It should reach for
get_current_time and resolve the date rather than guessing.
"Book the Orchard Room for John Smith please."¶
The interesting one. create_booking takes a room_id, and you gave it a
display name — and Hollowbrook's API is inconsistent about the field, calling
it id in one response and room_id in another. The detail file says so in a
line. A schema dump could not have.
Keep the booking id it quotes back; the next step needs it.
"Put it on their card please, the Visa ending 4471."¶
Where the half hour has been going. Watch three things happen in order.
It reads list_payment_accounts and finds pa_4471 -- a payment account
number, which stands for a card the house holds and is not a card number.
Nothing in this API returns one, which is deliberate: a transcript is replayed
to a model on every later turn, so a card number that reached one would be
there for good.
It reads charge_payment_account, and sends the booking's own total_pence
rather than a figure it worked out. The house refuses more than a booking's
total anyway, which is the guard for an agent that multiplied a nightly rate
itself.
And then the conversation stops. The reply says it is putting the charge through, and the thread shows it paused rather than failed -- a deliberate hold, not an error. Nothing was charged.
"Please book the Rose Room the following Friday, for three nights."¶
There is no Rose Room. A well-behaved agent refuses from what it already knows rather than calling an API to be told 404 — and above all does not invent one.
"Can you tell me a joke about monkeys?"¶
Nothing to do with the house, and the system prompt in step 2 said to keep to its business. It should decline and offer what it can do instead, with no tool call at all.
Worth trying because it is the one prompt here that is not about the skill. A skill says what an agent knows how to reach; the system prompt still says what it is for, and loading one does not dissolve the other.
5. Answer it¶
The charge needed somebody's say-so, and that somebody is you.
charge_payment_account.md opens with frontmatter saying so:
That is a rule about an operation, living in the file that documents the
operation -- versioned with the skill, immutable once published, and editable
only under skills:write. docs/approvals.md is why there
rather than on the agent, in a setting, or on the egress rule that permits the
host.
And the gateway is what stopped the charge, not the agent's good manners. When
the skill was published, the API recorded that POST /charges on Hollowbrook's
host needs a "charge"; when the turn started, it hashed that into the turn
token; and when the agent asked the gateway to make the call, the gateway
checked the rules it was offered against that hash and refused. A runtime that
dropped the rule from its copy gets nowhere, because the copy it can rewrite is
not the one the token vouches for -- the same move that makes egress rules worth
anything.
Here the walkthrough gets ahead of the code, and it is worth saying so rather than leaving you to discover it. The gateway refuses the charge; nothing yet turns that refusal into a request for somebody's word. Raising one is a call somebody makes:
curl -X POST localhost:18080/v1/approvals -b "outturn_session=$COOKIE" \
-H 'content-type: application/json' \
-d '{"session_id":"<the conversation>","requires":"charge",
"reason":"£260 to the Visa ending 4471","roles":["<a role id>"]}'
That holds the conversation and puts the request in the queue, addressed to a role rather than to a person: who may approve a charge is a question about the house's own organization, and it changes without the pending request changing. Answering it releases the hold and gives back any turn parked under it.
What does not yet follow is the charge going through. An approval records no
capability, so the resumed turn meets the same gate and is refused again --
docs/approvals.md's Not yet has both halves of what closing that loop needs.
Decline it instead and the hold stays on. The conversation stays paused, which is honest: nothing has changed about whether the charge may happen.
Worth doing twice, as two people. Sign in as somebody whose role does not carry
approvals:answer and the request is visible in their queue and unanswerable,
which is the difference between being asked and being entitled.
What you just exercised¶
- A skill as a manifest, with detail read on demand, so an API of two hundred operations costs the prompt the same as one of five.
- Deny-by-default egress. The agent reached one host because a rule permitted it. The sandbox holds no credentials and could not have reached anywhere else.
- An operator allowlist. Cluster-internal addresses are refused by default;
OUTTURN_INTERNAL_HOSTSis how somebody outside the workspace says which are intended. - Lazy tool loading.
load_toolsbeforeread_object, and again beforefetch_url, so unused tools cost nothing. - A rule that travels with the prose that documents it. Frontmatter on the operation's own file, versioned with the skill and editable only under the skill's authorities, rather than a setting somewhere else that could disagree with what the agent was told.
- A turn paused rather than failed. The conversation stopped where it was consistent, kept its place, and carried on when somebody answered -- which is a different thing from a turn that errored and was retried.
- An approval addressed to a role. Not to a person, so who may answer changes when the house reorganizes and the pending request does not. And an authority to answer that is separate from being asked.
- Usage attribution. The Dashboard has the tokens those turns cost, by model and by agent.
- A system prompt that still governs. The skill taught it an API; the prompt kept it to the job it was given.
Making it yours¶
The Hollowbrook component is a worked example of onboarding an API, and it is
meant to be copied. k8s/components/hollowbrook/ holds a Job that signs in and
calls outturn's public endpoints — create a skill, approve its host, upload its
files. Nothing privileged, and nothing patched into the platform, so replacing
skill/ with your own service's operations is most of the work.
The same goes for the look, which step 1 already had you run: ui/src/themes/
is one file per brand and VITE_THEME picks it, while VITE_BRAND_NAME,
VITE_BRAND_LOGO and VITE_BRAND_TAGLINE carry the name and the mark. Skinning
outturn is not forking it -- src/lib/brand.ts says why those are configuration
rather than source, and Logo.tsx is the one place the mark is drawn. The
published UI image reads the same four from its environment when it starts
instead, so one image serves every brand; see deployment.md.