The OpenAPI wizard¶
Turning a specification into something an agent can use, without paying for the specification on every turn.
Built: the generator, its endpoints and the page (The page below). The shape it rests on was tried by hand first and held; see The shape, tried once. Also built: the generated skill as a derivation, regenerated without losing what people added to it -- A derivation, not an output.
Why this first¶
An integration needs three things: a host the workspace is allowed to reach, a
credential bound to it, and a skill saying what to call. fetch_url supplies
the calling, so the only part that is real work is the skill -- and writing good
skill text for an API by hand is tedious enough that it does not get done.
Most services already have an OpenAPI document, often generated by the framework they are built on and served for free. Where one does not exist it can be written by hand against a REST service, and a partial document covering the three endpoints somebody actually needs is far less work than writing the prose those three endpoints deserve.
The constraint that shapes everything¶
A skill body is composed into the system prompt (skill::compose) and is
therefore paid for on every round of every turn, for the life of the
session. That is the whole design problem.
A real specification is enormous. Stripe's runs to several megabytes; an ordinary internal API has hundreds of operations, each with parameters, constraints, a request schema and several response schemas. Rendering one faithfully into a skill body produces something nobody can afford to carry, and trimming it by hand puts the work back where it started.
So the wizard does not translate a specification into prose. It splits it in two.
Manifest and detail¶
The same shape the guest's own tools already use. load_tools offers the model
a list of names and hands over the definitions when they are asked for, because
a tool that is not being used should not be occupying the prompt. An API's
operations are the same problem at a larger scale.
The manifest is the skill body. One line per operation: a name, what it is for, and where to read the rest. It is what sits in the prompt, and it is small enough to carry every turn even for a large API.
The detail is a file per operation, written to the object store. The full
parameter list with types and constraints, the request shape, what comes back,
and the errors worth handling. The agent reads it with read_object when it
decides it needs that operation, and pays for it only then.
Nothing new is required for any of this. read_object exists, the object store
exists, and PUT /v1/agent-sessions/{id}/files/{scope}/{path} is how something
above writes a file an agent can read.
What a file has to fit inside¶
read_object returns at most 32KB of a file at once, and a tool result is
truncated at 50KB or 2000 lines
(READ_BUDGET, MAX_TOOL_BYTES, MAX_TOOL_LINES in the guest). A larger
file is not an error -- the read returns its start and its end and says at what
offset to continue -- but an agent that has to make three reads to understand
one operation is paying for the split twice.
So every file the wizard writes should be readable in one call. An operation's detail is comfortably inside that; a manifest for a large API is not, which is what the categories below are for.
Where the files go¶
Into the skill itself, as files of the same version as the manifest
(skill-packages.md). An agent reads them as
skill/<slug>/<path>, read-only whatever its scopes, and an operator's skill
brings them to every workspace that binds it.
For a large API this grows one level, described under When the manifest itself is too large below.
The operation file name is the operation's name in the manifest, so an agent that has read the manifest can construct the path without being told it separately. That matters more than it looks: a path the model derives is a path it gets right, where a path it has to remember from elsewhere in the prompt is one it invents.
What the manifest line says¶
Enough to choose an operation, and not enough to call it. That division is the point -- a line that tried to be sufficient would be the specification again.
A name, a sentence, a path. The name is derived from operationId where the
specification has one and from the method and path where it does not, because
operationId is optional and frequently absent.
Names, not URLs, in the manifest. This is the failure
skill-evaluation.md was written about: a skill that
documents its call the way an API reference does -- GET
https://api.example.com/bookings -- reads correctly to a capable model and gets
called as a tool name by a weaker one. The manifest therefore never shows a URL.
A URL appears for the first time inside the detail file, in the company of the
method, the parameters and an explicit instruction to use fetch_url, where
there is enough context for it to be unambiguous.
What the detail file says¶
The operation, in the order somebody would need it:
- Its
operationblock, as frontmatter, where the frontmatter can describe it -- see Operations as tools below. - What it does, in a sentence.
- The call: method and full URL, with path parameters marked -- only for an operation that is not declared, since a declared one's tool builds it.
- Parameters: required ones first, each with its type and any constraint the specification states. Optional ones after, and only those with a described effect -- an optional parameter nobody can explain is noise.
- The request body's shape, where there is one.
- What comes back on success, as a shape rather than a schema: the fields worth reading, not the full response object.
- The errors worth handling, and what they mean.
Response schemas are where a faithful rendering goes wrong most often. A specification describes every field of a resource; an agent needs the handful it will act on. The wizard should render the success shape at one level of depth and say that more is available, rather than inlining a nested object graph.
The shape, tried once¶
Done by hand for Hollowbrook on 2026-09-22, before any of this was built:
k8s/components/hollowbrook/skill/ is one wizard run written out, a manifest
of five operations and a file for each, installed by the component's Job.
The question that mattered was whether a model reading a manifest line goes and
reads the file, or guesses the call from the name. Everything here rests on it.
Across five turns against a qwen3.8:27b-mlx agent, it read the detail file
first every time -- including the third, by which point it had seen the pattern
twice and had every excuse to skip. It also called load_tools for
read_object before the first of them, which is the same lazy-loading reflex a
tier down.
Six things beside that are worth recording, because each one is a place a faithful rendering of a specification would have failed:
- An inconsistency the specification cannot hide.
list_roomsreturnsid;check_availabilityreturns the same thing asroom_id; andcreate_bookingtakesroom_id. Asked to book "the Orchard Room" by its display name, the agent sent the right id. The detail file flags the clash in one line, which is the kind of line a schema dump does not contain. - A convention stated once. Money in pence and dates naming nights are in the manifest, not repeated per operation, and both held across every call: £260 for two nights at £130, and a 25th arrival with a 27th departure.
- A computed field used rather than recomputed.
total_penceis returned "so you do not have to multiply", and it was taken rather than derived. - A gap the agent noticed. Asked for "Friday", it loaded
get_current_timeand resolved the date rather than guessing one or asking which Friday. - A call it declined to make. Asked to book "the Rose Room", which does not exist, it made no tool call at all: the room list was already in its context from the first turn, so a request that was always going to 404 was refused from what it already knew. It still resolved "the following Friday" and "the same person", and offered the three real rooms. Inventing a room is the failure this platform's own preamble exists to prevent, and this is that preamble working.
- A prompt that still governs. Asked for a joke about monkeys, it declined and offered what it could do instead, with no tool call. Loading a skill teaches an agent what it can reach; it does not dissolve what the agent was told it is for.
What this does not yet say anything about is scale: five operations is where the split is least necessary, and a manifest for two hundred is the case the When the manifest itself is too large section below exists for.
Worked examples arrive later, and are observed rather than written¶
The obvious thing to put in a detail file is an example call. The wizard should not write one, because a generated example is a guess about what a model will do with the operation, and a guess in a file the agent trusts is worse than no example.
Examples come from evaluation (skill-evaluation.md). When a real turn calls an operation, that call is evidence rather than conjecture -- and it attaches to the operation as an annotation (see A derivation, not an output), rendered into the detail file, which is the same unit the agent already fetches.
What the example may claim is bounded, and that document sets the bound. A call that returned 2xx proves its form was right and says nothing about whether it was the right call: an agent fetching the wrong customer's invoices gets a clean 200 too. So an example shows how an operation is called and must not imply that calling it was the thing to do. It also records the turn it came from, so a session somebody later flags takes its examples down with it.
That ordering is deliberate. The wizard ships first and produces skills without examples; evaluation improves them afterwards from what actually happened. A wizard that invented examples first would have to be unwound.
Everything, by default¶
Because the prompt no longer grows with the specification, there is no reason to make the workspace choose which operations to include. Include them all, write a file each, and let the manifest carry the names.
That is not merely easier. A workspace choosing up front has to predict what its agents will need, and it will be wrong in the direction of omission -- the operation nobody selected is the one the agent turns out to need, and its absence is invisible. Including everything makes the manifest the only thing that grows, and a long manifest is a cost that can be measured and trimmed later against real usage rather than guessed at now.
When the manifest itself is too large¶
The split above moves the cost from the specification to the manifest, and for
a big enough API the manifest is still too much. An ERP with accounts
receivable, accounts payable, email and authentication runs to hundreds of
operations; at a line each that is tens of kilobytes in the prompt on every
turn, and it approaches the 32KB a single read_object returns before
truncation.
So the same trade is made once more. The skill body carries categories, and each category has its own manifest file listing the operations in it:
body categories, one line each
skill/<service>/<category>.md the operations in that category
skill/<service>/<operation>.md one operation
An agent asked about an unpaid invoice reads the receivables manifest and nothing else. The prompt carries a handful of category lines whatever the size of the API, and each file stays well inside a single read.
Categories come from the specification's own tags first. Almost every
generated document carries tags on its operations, written by the people who
designed the API and grouping it the way the domain actually divides. That is
better input than anything inferred, and it is free.
A model groups them only when tags fail, which happens in recognizable ways: no tags at all, one tag covering everything, or a tag per operation. Then a model reads the operation names and summaries -- not the whole specification, which is the point -- and proposes categories and subcategories. It is a one-off task at import, not something on the turn path, so it can afford a capable model and can be reviewed before it is saved.
Worth stating why the model is the fallback rather than the default: a grouping it invents is a guess about a domain it is seeing for the first time, where a tag is a statement by somebody who knows. The guess is good enough when there is nothing better, and worse than the tag when there is.
Subcategories follow the same rule, and are worth having only where a category is itself too large to read in one go. Nesting for its own sake costs a round trip per level, and a round the agent spends navigating is a round it is not spending on the work.
Hosts and credentials¶
The wizard reads servers and records each host in skill_version_hosts, which
is the existing declaration mechanism: a skill says which hosts it will reach,
the workspace's egress rules say which it may, and the difference surfaces as
unmet_hosts. So a freshly generated skill arrives already saying what it
needs, and the workspace is shown exactly what to allow.
It also reads securitySchemes, and records the name of the header the API
expects -- never a value, which the specification does not contain anyway.
That is enough to pre-fill an egress rule's header field, leaving the
workspace to supply only the environment variable holding the secret. The
credential itself never touches the wizard, the skill or the specification, as
integrations.md requires.
Designed to change: with sealed credentials the workspace supplies the key itself rather than a variable name, sealed in the page to the gateway's public key and bound to the skill's hosts and this header. The skill still never names a credential -- an operator publishes one skill, and each workspace that binds it seals its own key against it.
Where it runs¶
An API endpoint, with the existing skills UI around it. The operator is who runs it. The expected shape is a platform defining its integrations once -- supplying a specification, a URL to fetch or a file to upload, and receiving a proposed skill -- and its workspaces binding the result, the way they already bind the operator's other skills. A workspace running the wizard for an API of its own may come later or never, and nothing here should be built as if it were the main case.
That is also why the output cannot live in workspace/ scope: an operator's
skill sits in the platform workspace, and files in the platform workspace's
scope are unreachable from every other one. The wizard writes a skill package
(skill-packages.md), whose files belong to the skill and
resolve wherever it is bound.
Proposed rather than saved, because a generated skill is a draft. Whoever runs it sees the manifest every bound agent will carry on every turn, and the host list each workspace will be asked to allow, before either becomes real.
Parsing is the hazardous part. A specification is untrusted input --
fetched from somewhere the operator does not control even when the operator is
the one asking, and from a workspace if that ever comes -- parsed in the API
tier, and the failure modes are the ordinary ones
for structured formats: deeply nested structures, enormous documents, $ref
cycles. Bound the document size, bound the recursion depth when resolving
references, refuse rather than truncate, and do it before any of it is
rendered.
A URL fetch is the other hazard, and it has an answer already: fetching a
specification from a host is reaching a host on a workspace's behalf, which is
what the gateway is for and what the egress rules govern. It is not a new path
-- the API asks the gateway, exactly as an agent's fetch_url does. Uploading
a file avoids the question entirely and should be the path that works first.
The page¶
Built: the generator, POST /v1/platform/skills/from-openapi and its
/preview, and the page in front of them at /skills/import. It does three
things in order.
Take a specification. Pasted, uploaded, or named by URL. Upload comes
first and is the path that must always work, because it also answers every
specification behind a login: the person downloads it with their own
credentials and hands us a file, and no credential of theirs passes through
the platform. A URL is fetched by the server, unauthenticated, and only through
the gateway as the section above says -- never by a client in the API, which
would be a URL field that reaches outturn-api and everything else inside the
cluster. Fetching from the browser instead is deferred and may never happen:
the obstacle is CORS, not anything of ours, and most API hosts do not serve
their specification to another origin.
Read what it already says. The specification usually answers most of the
form. servers[0].url is the base URL; a bearer scheme names
Authorization, an apiKey scheme in a header names that header; info.title
and info.description are the name and description, and the slug follows from
the title. The scheme is the one the most operations ask for -- read per
operation, since many specifications declare security there rather than at
the root -- among those an egress rule can serve: one static header, so an
API key in a header, a bearer token, or basic credentials precomputed. OAuth,
OpenID Connect, mutual TLS and a key in a query or cookie cannot be, and the
page says how many operations need one and that the skill will not
authenticate those on its own. The header confirmed there goes to the create
as well as into the rule, so the skill tells the agent about the same header
the rule attaches. So the request splits in two: a preview that parses and returns
these, with the operation and category counts, and the create that already
exists. Everything prefilled stays editable, and anything the specification
did not say is asked for -- a missing servers is common, and a relative one
has to be resolved against the URL it was fetched from.
Create the skill, and say what it still needs. The skill's hosts are
written with it, but a host is reachable only once an egress rule names it,
and the credential is an environment variable name the operator sets on the
gateway. The page asks for that name, prefilled from the service
(OUTTURN_EGRESS_BIGCAPITAL_API_KEY), and ends by stating plainly what is still to be done
outside it, since the variable itself is not something a browser can set.
With sealed credentials that last step becomes a field: the key is pasted,
sealed in the page, and the rule written, and nothing is left to do outside.
Settled when it was built: the API mints a narrow token rather than the
gateway growing a path for the API tier. It carries the gateway audience, an
egress commitment over the one host the URL names with no credential, no
gates, and Role::DocumentFetch -- which holds GatewayFetch and not
GatewayInvoke, so it cannot call a model -- and it lives for a minute inside
the API and is dropped. The gateway treats it like any turn's request, private
addresses refused, the answer pinned, no redirect followed, with two
differences keyed on the role: only GET, and a response as large as the wizard
accepts rather than a turn's 256KB, which would cut every real specification
short. An internal host the operator opened is reachable by it, as it is by a
turn; only the operator runs this, and it is the operator's list. Fetching
needs OUTTURN_GATEWAY_URL on the API, as the namer does, and refuses with a
pointer to upload when it is unset.
A derivation, not an output¶
Built: the kept specification (skill_sources, skill_source_revisions),
annotations (skill_annotations), regenerating as a proposal or a published
version (api::skill_sources), and the section of a skill's page that adds
notes and regenerates. Not yet: detaching a skill from its source, refreshing a
specification from the URL it was fetched from, observed examples, and the
auto-approval keys. What follows is the design, and what it was built to.
What the wizard produced was final: a skill version, whose body and files are the end of the line. Anything somebody adds afterwards -- an approval rule in an operation's frontmatter, a sentence saying "never use this one, use the batch endpoint", an example -- is an edit to that output, and the next import of an updated specification writes over it. So nobody adds anything, and the generated skill stays exactly as good as the specification, which for many APIs is not very.
The fix is to stop treating the skill as the thing being edited. A generated skill is a derivation: a pure function of a specification and a set of annotations, published as an ordinary version. People edit the inputs; the platform derives the output; agents read only the output.
skill_sources (skill_id, kind, fetched_from, base_url, auth_header)
skill_source_revisions (id, source_id, spec_sha256, fetched_at, created_by)
operation_annotations (id, skill_id, operation_key, kind, body jsonb,
created_by, created_at, retired_at)
skill_versions.derived_from (source_revision_id, annotations_as_of)
The specification is stored by hash in the object store, as a skill's files are. A revision is one fetch or upload of it.
What an annotation is keyed on¶
What the specification names, at the level it has to be seen. Three levels: the whole skill, whose notes go in the body every turn carries; a category, by its tag, whose notes go in its file; and an operation, by the name the skill shows it under, whose notes go in its file. Which level is the curator's choice, and it decides who sees a note when. A vocabulary note -- "invoices are called sale invoices here" -- belongs at the skill level even though it is about one category, because an agent that does not know it cannot tell which category to open: a note inside the category file is read only by an agent that already found it. Detail that matters once an agent is there goes lower, and keeps the body small.
The operation, not a location in the document. operationId where the
specification has one, and the method and path where it does not -- the same
rule the manifest already uses to name an operation, so an annotation and the
file it renders into are found by one key.
That rules out the OpenAPI Overlay specification as the storage format, though it is the published standard for amending a document. An overlay targets a JSONPath into the specification, and a JSONPath is a location: a revision that reorders or restructures the document moves the target and the action lands somewhere else, or nowhere, without saying so. Keyed on the operation, a note follows the operation wherever the document puts it. Exporting annotations as an overlay, for somebody else's tooling, is a different matter and costs nothing.
Kinds¶
Each one changes exactly one thing in the output, and says which:
note-- prose for the agent, rendered into the operation's file under a heading of its own, after what the operation does and before how to call it: a business rule is worth reading before the URL, not after the error table. The examples a specification lacks, the constraint it never states, the field that is really required.prefer-- "usebatch_create_invoicesinstead", naming another operation. Rendered into both the manifest line and the file, because the manifest is where the choice is made.hidden-- left out of the manifest and the files. Advice, not enforcement: an agent can still compose a call to the path. An operation that must not be called is a gate, not a hidden one. A gated operation is never hidden, and the pair is refused: the gate lives only in the operation's file, so leaving the file out would remove the gate while its host stays allowed.approval-- the frontmatter rule from approvals.md, includingrisk,auto,numbersandfreefrom auto-approval.md. Rendered as the file's frontmatter and parsed by the same parser at publish, so a rule is refused for the same reasons whether it was typed into a file or into an annotation. Regenerating refuses to publish a version that would drop a gate the live version has unless toldremove_gates, so retiring an approval annotation is a decision made twice rather than once by accident; and a skill adopted from before sources were kept has the rules written into its files by hand turned into annotations as it is adopted. The page offers a rule per operation, guessed from its method and names and never applied unasked: reads low and ungated, creates and updates medium, deletes and anything named for money or irreversibility high and gated, with anx-riskextension in the specification taking precedence, and the amount field and currency read from the body's field names and what the specification says of them (auto-approval.md, "Where the wizard comes in"). They are confirmed on a page of their own,/skills/{id}/review, which walks them in groups -- money, deletes, changes, then reads, folded away -- with each confirmed or changed one at a time or a group's guesses at once, written one by one so the count moves. A rule binds the operation's path parameters as well as its body fields, with itsmatchesnaming them (DELETE /invoices/{id}), so a delete by id is bound by the id; an operation with neither has nothing for a rule to bind, so it is shown but cannot be confirmed.example-- an observed call, from evaluation, carrying the turn it came from, so a session later flagged takes its examples down with it.-
skill_note-- prose for the body rather than one operation: a convention stated once ("money is in pence"), which is what made the hand-written Hollowbrook skill work. -
identity-- the fields whose values make one of an operation's writes the same as another, so the platform makes it once however often it is sent (idempotency.md). The page offers the wizard's guess -- a create's required fields -- to confirm or correct, and never applies one unasked: a wrong identity is wrong silently, merging two invoices for one customer and amount into one. A field the specification no longer has is left out when the skill is made.
Regenerating¶
A new revision, or a changed annotation, proposes a new version. It does not
publish one. What an operator sees is the difference that matters: operations
added and removed, operations whose call changed, and annotations whose
operation is gone. Those are kept and shown, never dropped: a business rule
that silently stopped applying because somebody renamed an operationId is a
fact leaving without anyone seeing it go. Where the method and path still match a
new operationId, re-keying is offered rather than done.
Publishing is a person's act, as it is for every version: a regenerated skill changes what every bound agent is told, and the gates it declares change what the gateway refuses. A scheduled refresh may fetch the specification and propose; it raises an action item, and stops there.
What stays the same¶
Agents read published versions, and only those. Annotations are never read on the turn path. A turn's gates are computed from the version it bound, an approval is recorded against the file it declared, and the transcript can say which text a turn ran against -- all of which approvals.md rests on, and all of which would be lost if a turn could see a note edited after it started.
The files of a derived skill are not edited directly. An edit to output that the next derivation will overwrite is the problem this exists to remove. The editor shows a derived skill's files read-only, with each annotation beside the operation it applies to and a way to add one. A skill that somebody wants to edit freehand can be detached from its source -- it becomes an ordinary package from that version on, and stops regenerating.
A workspace's overrides of an operator's derived skill stay what overrides are: prose about the base, composed after it. Whether a workspace should be able to annotate an operator's operations -- its own business rules on a shared integration -- is the case overrides were invented for, at a finer grain, and is not settled here.
Operations as tools¶
Each operation's file declares it in an operation block -- method, URL, and
its arguments as a JSON Schema -- so the agent is offered it as a tool and the
request is built for it (skill-operations.md). The
manifest then tells a model to load operations by name and call them.
The schema is translated from the specification's, which is OpenAPI's dialect
rather than JSON Schema's: a named component becomes a shared type of the
skill, kept once in types.json; other references are followed; allOf is
merged into one object; nullable becomes a type list; and what a request
does not send -- a readOnly field, an example, an XML hint -- is left out.
Path and query parameters sit beside the body's fields as one object.
Not every operation fits. A body that is not JSON -- an upload, a form -- or
not an object, or a schema that expands past what is worth sending each round,
is written as before: its call spelled out for fetch_url, and the manifest
saying which kind each is -- "most" or "some" by how many fit.
A header is never the model's to send, so it never keeps an operation from being a tool: the credential is attached by the platform, and any other header is left out and described in the prose as one the tool does not send. Not refused when the specification marks it required, because that mark is often a condition the format cannot state. One accounting API marks an organization header required on 313 operations and means "required if the token is a JWT"; with an API key it is not needed at all, and refusing on the mark left every operation a model would reach for uncallable. A header an API really needs on every call is a fixed value the connection should supply and the gateway attach -- not built yet.
A person's approval rule goes into the same frontmatter. Where the rule does not fit the operation -- it gates another path, or binds a field the body does not have -- publishing would refuse the file, so the operation is left undeclared and the rule kept as it was written.
What this does not do¶
It does not generate tool definitions for what a flat list cannot say. An
operation outside the frontmatter's narrow shape stays prose, composed into
fetch_url by the model. The manifest-and-detail split survives either way.
It does not constrain what the agent sends. A conduit operation -- one whose arguments decide who is reached rather than what is read -- cannot be made safe by documentation. That needs argument constraints, which prose cannot express, and is the strongest argument in the fork.
It does not validate the specification against the running service. A document that lies about its own API produces a skill that lies about it too. Evaluation is what catches that, by calling the thing and seeing what happens.