Write a tool

Return results and files

Fit results to a model budget and pass large data between tools.

For: plugin authors.

Fit results to a model budget and pass large data between tools.

Results that fit a budget

A tool's answer goes into the model's context whole. A tool whose answers can be long (a web page, a file, a diff, a log) can instead answer with the biggest part that fits a budget the caller gives, and say what it left out. This is a convention any plugin may adopt; the verified web and GitHub plugins and the board's oz.board_read follow it.

A function that adopts it takes three optional arguments:

ArgumentWhat it is
viewstub (metadata, size and an outline: headings, a file list), summary (the outline with each part's first sentence, made without a model), section (the part query names, whole parts only) or full. Left out: the biggest that fits.
budgetThe most tokens the answer's content may take.
queryFor a section: a heading, a definition's name, a path, <path>#<n> for a diff's n-th hunk of a path, or a line range such as 120-180.

Left out, view is the biggest view that fits: full, or section when there is a query, then summary, then stub. A view that was asked for and does not fit falls to the next smaller one, never to a bigger one. The stub is the floor: when nothing else fits, it says how big the whole is and how to ask for more. A call without any of the three arguments is answered as before the function adopted the convention.

A fitted answer holds at most 50,000 tokens and 200,000 characters (oz0.fit.CAP and CAP_CHARS), whatever the budget and with none, such as a flow step that names only a view, and at most 8 characters for each token of the budget (oz0.fit.chars_of). The tokens a view is measured in are oz0.fit.weigh: the platform's estimate, with long runs of white space charged by their length, since the estimate alone takes a page of spaces for a few tokens. A tool may pass a bound of its own in characters (chars=).

A bigger whole falls to a smaller view, whose fit note says which bound was the reason, and the verified plugins set their truncated field. Ask for a section or a line range to read past it.

Every fitted answer carries a fit note, in its content's fit field and in the result's _meta under oz0/fit:

{"view": "section", "budget": 2000, "size": 12340, "tokens": 1840,
 "omitted": "This is the section matching 'Install'; the rest of the page (10500 tokens) was left out."}

size is the whole answer's estimate and tokens what was returned. The job's trace shows the view and the numbers of each fitted step (job trace lists them under "Results fitted to a budget"); it never shows the omitted sentence, which names headings and paths of the content.

Declare it in the manifest, so the runtime fills in a budget the model leaves out:

oz0-plugin.yaml
tools:
  - id: acme_docs
    mcp: ["python", "-m", "acme_docs.server"]
    results: {fit: true}

The runtime fills in budget only for the tool's functions whose input schema has an integer budget property beside a view whose values are the views above (an enum, as a Literal type gives), and only for a model's calls (not a flow step's). A budget without such a view, such as an amount of money, is never filled in. It adds it to the call's arguments before anything decides the call, so the tenant's tool policy, hooks and a person's approval see the budget, and the call is made with the arguments the person approved.

The default is a quarter of what is left of the agent's context, taking the context as 128,000 tokens and what is used as the newest model request and its answer, at least 500 and at most 8,000 tokens, and half of that once the job tree has spent the warning share of its budget. plugin lint checks the block, and, when the plugin has a measured listing, warns when no function of the tool takes a budget and names each function that has a budget without the convention's view.

In Python, the module oz0.fit of the Python SDK (oz0-client) does the work without anything else installed (it uses only the standard library, so you may also copy the file into your plugin): count estimates tokens as the platform does, and fit_document, fit_diff and fit cut the views.

src/acme_docs/server.py
import json
from typing import Literal

from mcp.types import CallToolResult, TextContent
from oz0 import fit

View = Literal["stub", "summary", "section", "full"]


@server.tool(annotations=READ)
def get_page(path: str, view: View | None = None, budget: int | None = None, query: str | None = None):
    text = read_page(path)
    if not fit.wanted(view, budget, query):
        return {"content": text}
    f = fit.fit_document(text, view=view or "", budget=budget or 0, query=query or "", head=path, name=path)
    answer = {"content": f.text, "fit": f.meta()}
    return CallToolResult(
        content=[TextContent(type="text", text=json.dumps(answer))],
        structured_content=answer,
        _meta={fit.META_KEY: f.meta()},
    )

The estimate is the platform's own approximation of a cl100k-class tokenizer, not a provider's count: about 3% off on average (what a definition costs). Go code uses pkg/toolcost's Count, which gives the same numbers. A summary is cut from the text, never written by a model: a tool that hid a model call would spend money nobody budgeted. Fitting answers to a budget changes what the agent reads, and so may change what it does next; measure a change of budgets on your own agents before you rely on it.

Large results

A result whose text and structured content come to more than 31 MiB (as UTF-8 JSON) is answered as a failed call: the model reads that the call was made and its result is not used, and to ask for less. The node keeps a call's result on the edge as one payload of at most 32 MiB, so a larger one could not be kept, and the call is not made again for it. An offloaded result counts the same: the call's result keeps the whole text beside the file the model reads a stub of.

Any tool's result can be too long for the model's context, whether or not its tool follows the convention above. The platform's runtime handles that for every tool, with no change to the tool: when a result's text weighs more than the job's threshold (in the platform's token estimate, oz0.fit.weigh), the tool's node keeps the whole result as a file of the job's tree, and the model reads a stub instead:

{"offloaded": {"ref": "oz0-file:sha256:9f2c...", "kind": "markdown", "size": 192331, "tokens": 48213, "lines": 2140},
 "outline": "# Release notes\n## Part 0: Part 0 begins here.\n## Part 1: ...",
 "note": "The result of acme_docs.get_page (48213 tokens, 2140 lines) is more than the 10000 tokens this agent reads inline, so it was kept as a file of this job and left out here; ..."}
  • The outline is made without a model: it is the result's outline summary (each heading or top-level key with its first sentence), or its stub (the headings alone) when the summary does not fit a quarter of the threshold, at most 1,000 tokens. It costs nothing, the result's text cannot steer it, and the same text always gives the same stub.
  • What the result is read as: a JSON object or array is read by its top-level keys or items, one indented line per value, so its line ranges mean something even when the tool wrote it on one line; Markdown by its headings; anything else by runs of lines. lines counts that form.

The model reads parts back with oz.read_result

oz.read_result is a built-in offered in every request of such a job (a job in which no agent lists a plugin tool has no result to offload, so its requests do not offer it; an openai-agents router's hand-off targets run in its job and count): oz.read_result(ref, view?, section?, range?, query?, budget?). section is a heading, a top-level key or an item such as [3]; range is lines such as 120-180; view and budget are the convention's, and a budget the model leaves out is filled in as for a fitted tool. The answer carries the fit note.

  • Only the job tree that produced a result can read its ref, as for any file between tools: another tree, or another tenant, is told it is not a file it may read. The file keeps the result's labels; reading it back joins them into the job, and a result above the agent's clearance is withheld. The file is job data: sealed where payloads are encrypted, kept under holds, removed by retention and by erasure.
  • What stays whole: the job's history, its trace (which shows the result's size and digest, never its outline), the cards drawn from the result, and the post_tool_call hooks, which see the whole result before the stub is made. A hook that changes the result keeps its own value inline. The live stream and an AG-UI stream carry what the model read: the stub and its ref.
  • Only a model's calls are offloaded: a flow step's results go to the next step whole. A harness session's own tools (its shell, its file reads) are the harness's; an ACP session's calls of the tenant's tools are offloaded, and the session gets oz.read_result from the platform's tool server. A remote agent's calls are not offloaded.

A tool that follows the convention keeps its own views. Offloading applies to what it returns: with the default threshold of 10,000 tokens, an answer the runtime fitted to its default budget (at most 8,000) stays inline, and only an answer the model asked to be bigger is kept as a file. Fit your tool's answers when you know its units better than a generic outline does (a diff's hunks, a board's notes).

The threshold is the agent's offload (a number of tokens, or off; see the agent definition), within the tenant's ceiling, else the tenant's default, else 10,000 tokens. The default is provisional: it comes from the budget the runtime gives a fitted tool, not from a measurement, and oz0 compare-savings start --variant-offload is how to measure it on your own agents (measured savings). A tenant admin sets the default and the ceiling with oz0 tenant offload (where limits live). A job keeps the threshold it started with.

Files between tools

A tool can return a file for another tool, such as a repository's archive for a test runner, without the bytes passing through a model or the job's history: it returns an MCP embedded resource whose URI is oz0-file:sha256:<hex> and names that URI in its result; a call whose top-level argument is such a URI gets the file as a path in its _meta under oz0/files (the manifest reference has the details).

Who may read a file:

  • The job tree that stored it. A tree is a job with everything it started: its delegated agents, a flow's steps, a flow tool's flow. The edge knows the tree from the call binding of the activity that asks, which only the edge can make, never from what the node says. Any other job, and any other tenant, gets "not found", as for a file that was never stored: a digest a model saw opens nothing outside its own tree.
  • Later turns of a conversation. A turn reads the files the turns before it stored; a conversation forked from a turn reads the files up to that turn, never a later turn's or another branch's.
  • A job a person hands it to. A person or an app with the content permission may start a job, or call a tool by hand, with a file another job stored as a top-level argument: oz0 tool call oz0_github.download_archive and then oz0 run fixer with the archive works. The start is audited as job.files_handed. Without the content permission the start is refused; a file they may not read (another person's private chat) is left out, and the job's tool then gets "not found".

A tree is named by its first job's ID. So that a new job never inherits an old job's files, a start under an ID you chose (oz0 run --id, oz0 tool call --id, a StartJob with id) is refused while the tree of that ID still holds files and its job has ended, and when the ID is a conversation's turn; while the job runs, the start follows it as before and hands it no files.

Files are kept on the edge, per tenant, at most 32 MiB each, for 90 days after a tree last stored them. Where the tenant's payloads are encrypted (in a cluster by default) they are sealed with the tenant's payload key. They are job data: retention, legal holds and erasure reach them.

An agent's keep-for period does not lengthen the file's payload-retention period. Held copies of workflow histories do not include these files. Preserve files separately when your retention requirements need them beyond that period. Files written by older runtimes can have tenant-wide access and are not reached by erasure until associated with a job; upgrade every runtime before relying on scoped-file erasure.

Copyright © 2026