Skill operations as tools¶
A skill's operations, offered to the model as tools, and translated by the
guest into the fetch_url request the operation's own file already describes.
Designed, not built.
The problem¶
Calling a skill's operation today takes three mechanisms, and a model has to know which is which:
| Wanting | Means |
|---|---|
| a tool | load_tools, then the tool |
| what an operation is | read_object("skill/<name>/<operation>.md") |
| to perform it | a fetch_url request composed from that file: method, URL, headers, body |
Every gap between them has been fallen into. Small models, one afternoon, one guesthouse:
- asked
load_toolsforcheck_availabilityandcreate_booking, which are operations and not tools, and were handed back the list of things they could have asked for; - fetched
skill/hollowbrook/list_rooms.mdfrom a host invented for it, rather than reading it; - posted a booking without
Content-Type, was refused415, and resent it twice -- one of which took the room.
None of that is a model being unreasonable. "Can I do this?" has three answers depending on what this is, and the model is expected to read a document to learn the shape of a request the platform could have built for it. A larger model copes, but coping is not the same as being given the right interface, and an evicted pod's retry does not read documentation either.
The shape¶
An operation is a tool. Its file says what it is, and the guest does the rest:
- The file declares the operation in its frontmatter, beside
approvalwhere one exists:
---
operation:
method: POST
url: http://outturn-hollowbrook:8084/bookings
body: [room_id, guest_name, arrival, departure]
---
Arguments are declared one of two ways.
A schema, which the OpenAPI wizard writes: the tool's arguments as one
JSON Schema object -- path and query parameters beside the body's fields --
in an indented block, with query: [...] naming what goes in the query
string. Path parameters are named in the URL, and whatever else the schema
names is the body.
operation:
method: POST
url: http://petstore:8080/api/v3/pet
schema: |
{
"type": "object",
"required": ["name", "photoUrls"],
"properties": {
"name": {"type": "string"},
"category": {"$ref": "#/types/Category"},
"photoUrls": {"type": "array", "items": {"type": "string"}}
}
}
A type more than one operation uses is written once, in the skill's
types.json, and referred to as #/types/Name. Publishing expands the
references, because a provider takes each tool's schema standing alone and
some refuse references outright; a type that contains itself stops at "an
object", and an expanded schema over 24KB is refused. So the file reads the
way a person would write it, and what a turn is given is what a provider
accepts.
Flat lists, the shorthand for writing one by hand, as Hollowbrook does:
bodyandqueryname the JSON body's fields and the query string's parameters. AGETorHEADhas no body.- A path parameter is a whole segment of the URL,
/bookings/{id}, and is always a required string. optionalnames the body or query fields that may be left out; the rest are required.integer,numberandbooleantype the fields that are not strings.listnames the fields that are lists of their type: a query parameter sent repeated,tags=a&tags=b, or a JSON array in a body. A list of integers is in bothlistandinteger.
They describe the same schema, which is the only thing a turn is given.
A name lives in one place, and a qualifier must name a field that is sent:
a typo in optional would otherwise leave required the field it meant.
-
The guest offers it as a deferred tool,
hollowbrook_create_booking, named by skill and file so two skills cannot collide, and reduced to the letters, digits,_and-every provider accepts in a tool name. Its description is the first paragraph of the file's prose -- what the operation is -- and its parameters are its schema.load_toolsloads it like any other -- and by the name its skill's manifest lists it under,create_booking, where one bound operation has that name. That is the name a model reading the manifest asks for, so the call every small model tried first is now the right one. -
A call becomes the
fetch_urlrequest the file describes: the method, the URL with its path parameters filled in and encoded, the query,Content-Type: application/jsonand the body. A call missing a required argument, or carrying one the operation does not take, is not sent: half a request that reached the recipient may already have done something. It goes out through the same host import, so the result the model sees is the onefetch_urlwould have returned.
The prose stays, and stops being required reading. The body of the file is
still where judgment lives -- charge only after a booking, quote the id to the
guest -- and read_object still reaches it. What the model no longer has to
get from it is the shape of a request.
fetch_url stays too, for everything that is not a declared operation. A
skill without operation blocks works exactly as it does today.
Why a translation, and where it happens¶
The roadmap used to leave this as a fork, tool registration: a registered tool in the guest needs its definition to cross the WIT boundary, and in the host it is a tool the component does not reason about. Both are right about registering a tool. This is narrower. It registers nothing: an operation is a declaration the API has already read, translated into a call the guest can already make.
The API parses; the guest is handed the result. The frontmatter is parsed
at publish, where a malformed declaration is refused to the person publishing
it. The guest cannot share that parser -- it is a wasm component whose one
dependency is a JSON library -- and reading and parsing every operation file
at the start of every turn would be work done again for an answer that cannot
have changed. So the turn carries the bound skills' operations, already
parsed, and the guest asks for them through one host function,
skill-operations, as it asks for eager-tools now. That is the only
interface change, and it adds a question rather than changing an answer.
Everything downstream is untouched:
- No gateway change. The request that arrives is an ordinary egress request. The egress rule, the credential binding, the approval gate and the commitment all apply to it as they would to the same request composed by hand -- because it is the same request.
- Nothing to trust. A guest that translates can only produce a request
it could already have sent through
fetch_url. A compromised runtime that mistranslates gains nothing it did not have, which is the property every other part of the agent interface is held to.
Why it is not brittle¶
The declaration is the one place the operation's shape is written. The tool the model is offered and the request the guest builds are both read from it, parsed once by the API, from the version the turn bound. They cannot drift, because there is nothing to drift between.
What can disagree is a gate and an operation written in one file by hand:
approval.matches: POST /charges beside an operation whose URL is
/charge. The parser refuses that at publish -- a gate in a file with an
operation must match that operation -- so the disagreement is found by whoever
wrote it rather than by an agent that could not be charged for.
Outside outturn¶
A skill here resembles the public Agent Skills convention without claiming
compatibility (skill-packages.md), and an operation file
is already a level that convention has no equivalent of. operation changes
nothing there. What it would change is the prose: once the tool builds the
request, the "The call" section that told a model the method and URL is
redundant here -- and the only thing an agent elsewhere had to go on.
So the prose may drop it, and wherever a skill leaves this platform the line is generated back from the declaration, so it is still written once. Nothing exports a skill yet; this is what an export has to do when one is built.
The wizard¶
The OpenAPI wizard already writes these files from a specification; it writes
the operation block from the same operation it writes the prose from, so a
wizard-built skill gets tools without anyone typing a schema.
What it leaves to the host¶
Idempotency, by the argument in idempotency.md, Why the
host owns it. An operation block is also where its identity is declared --
identity: [room_id, guest_name, arrival, departure] -- and that has to reach
the gateway the way a gate's binds does, through the commitment, so the key
is derived on the host from the request it actually sees. The translation
needs none of that to be useful, and lands first.
Hollowbrook¶
Its operation files gain operation blocks, and their prose loses the "The
call" sections that described the request by hand. It is the test of whether
the shape is right: a booking that small models spent an afternoon fumbling should be
load_tools and one call.
Not built¶
In order:
- ~~
operationin the frontmatter parser, with the publish-time check against a gate in the same file.~~ Built. - ~~The operations stored with a version, and carried to the turn.~~ Built.
- ~~
skill-operationsinwit/agent.wit, answered by the runtime.~~ Built. - ~~The guest offering declared operations as deferred tools, and translating
a call into
fetch.~~ Built. - ~~The wizard writing
operationblocks.~~ Built; see openapi-wizard.md, Operations as tools. - Hollowbrook's files converted, and an end-to-end test against the fake gateway that books a room through the tool.
identity, with idempotency.md.