20 KiB
pi-native tool-call format
The pi-native format is the tool-call serialization used by the omp / pi coding agent. Unlike the JSON-in-a-tag conventions (Hermes/Qwen, Harmony) and unlike the fully separate JSON content-block channel (Anthropic Messages API), pi-native serializes each call as an XML-flavored block whose tag carries the tool name — <call:NAME>…</call:NAME> — and whose arguments are child elements named after the parameters. It is schema-driven: the tool's JSON Schema decides how each value is typed (string vs number vs object vs array) and which compact spellings are legal.
This document is a specification of the format (it is the contract a renderer must emit and a parser must accept), not a reverse-engineering of trained model weights. It is designed around four goals:
- Token economy — the common cases (a single scalar argument; a single string payload) collapse to one short line.
- Verbatim payloads — a large multi-line string argument (a patch body, a file's contents, a shell script) is carried raw, with no JSON string-escaping and no entity-encoding, terminated by the call's own unique closing tag.
- Human legibility — a call reads like the function it denotes; nesting maps to nesting.
- Lenient parsing — the tags are plain text matched by a tolerant parser (regex / streaming state machine), not a strict XML parser; output is not required to be well-formed XML.
Scope: pi-native specifies only the tool-call (and argument) serialization. It is envelope-agnostic — the <call:…> blocks are emitted as ordinary assistant text and embed unchanged in any conversation envelope (ChatML, Harmony, the Anthropic two-role shape, …). Reasoning channels, role markers, and result delivery are the host envelope's concern; the one envelope-level requirement pi-native imposes is in Tool-result correlation.
Lineage: the attribute spelling (<call:read path="…"/>) follows Anthropic's modern attribute XML (<invoke name="…"> / <parameter name="…">); the schema-driven unquoted value rule (a bare string value carries no quotes; non-strings are JSON) follows GLM-4.5's <arg_value> convention. pi-native folds both into one recursive, name-on-the-tag grammar and adds the verbatim inline body for bulk string arguments. See anthropic.md and glm-4.5.md in this folder.
Structural tags
pi-native has no special tokens. Every marker is plain UTF-8 text that BPE-splits like any other text and survives detokenization unchanged; a parser matches the tags as literal substrings (and MUST work even when the surrounding stream is not valid XML). All brackets are ASCII < > / and the literal colon :. There are no namespaces, no <?xml?> prolog, no entity expansion, and no CDATA sections.
| Tag (verbatim) | Role |
|---|---|
<call:NAME …> … </call:NAME> |
One tool call. NAME is the tool/recipient name; it is repeated on the closing tag. |
<call:NAME …/> |
Self-closing tool call (all arguments supplied as attributes). |
<KEY> … </KEY> |
One argument (or one nested field). KEY is the parameter name. |
<KEY …/> |
Self-closing argument: an object-valued field whose scalar sub-fields are attributes, or an empty value. |
KEY="…" / KEY='…' / KEY=… |
An attribute: a scalar field given inline on a tag. Quotes are delimiters, not type markers (see value coercion). |
Names (tool names and parameter names) match ^[A-Za-z_][A-Za-z0-9_-]*$. The call: prefix is a literal four-character marker plus the colon; the colon is what distinguishes a call block from a nested argument element of the same name.
Tool-call forms
A single call has three interchangeable surface forms. Which forms are legal for a given tool is decided by its parameter schema; a renderer SHOULD pick the most compact legal form, and a parser MUST accept all three.
1. Element form (canonical, fully general)
Each top-level argument is a child element named after the parameter; the element body is the value. This form expresses every schema — scalars, strings, arrays, and nested objects:
<call:read>
<path>src/server/auth.ts</path>
<offset>50</offset>
</call:read>
→ read({ "path": "src/server/auth.ts", "offset": 50 }) (offset is JSON because the schema types it as a number; path is a verbatim string).
2. Attribute form (compact scalars)
When the arguments being passed are top-level scalars (string, number, integer, boolean, null), they MAY be written as attributes on the call tag. With every argument as an attribute the tag is self-closing:
<call:read path="src/server/auth.ts"/>
→ read({ "path": "src/server/auth.ts" }).
Attributes and child elements MAY be combined on a non-self-closing call tag — attributes carry the scalars, child elements carry anything structured:
<call:read path="src/server/auth.ts">
<offset>50</offset>
</call:read>
An attribute whose value cannot be represented as a scalar (an object or array argument) MUST use the element form instead — there is no attribute spelling for structured values on a call tag.
3. Inline-body form (verbatim string payload)
When the tool's parameters are all strings — most often a single string parameter — the call body MAY be the argument value written verbatim, with no child element tags:
<call:edit>
*** Begin Patch
@@ src/server/auth.ts
- return user;
+ return user ?? null;
*** End Patch
</call:edit>
→ edit({ "input": "*** Begin Patch\n@@ src/server/auth.ts\n- return user;\n+ return user ?? null;\n*** End Patch" }).
Rules for the inline body:
- The body fills the first parameter not already supplied by an attribute. With no attributes that is simply the first parameter (the "first argument verbatim").
- It is permitted only when that target parameter is string-typed (the enabling condition "the type only contains string arguments"); any other parameters set on the same call MUST be scalars given as attributes.
- The value is captured verbatim up to the call's own closing tag
</call:NAME>. No JSON escaping, no entity decoding. The body MAY freely contain<,>,&, quotes, JSON, even other<call:…>-looking text — the only sequence it MUST NOT contain is the literal closer</call:NAME>. Because that closer carries the tool name, collisions are far rarer than with a short generic delimiter. - Whitespace: a single newline immediately after the opening
>and a single newline immediately before the closing</are treated as block delimiters and are not part of the value; all other whitespace (indentation, blank lines, trailing spaces) is preserved exactly.
A multi-string tool can still use the inline body for its bulk argument by passing the others as attributes — the body then fills the first parameter left unset:
<call:write path="notes/todo.md">
# TODO
- ship pi-native parser
</call:write>
→ write({ "path": "notes/todo.md", "content": "# TODO\n- ship pi-native parser" }) (here path is given by attribute, so the body fills the next string parameter, content).
Inline-eligible tools may always fall back to the element form; <call:edit><input>…</input></call:edit> and the inline <call:edit>…</call:edit> are equivalent.
Value model
The body of a call (and of any nested element) maps to JSON by a single recursive rule set. Typing is driven by the parameter's JSON Schema; the parser only falls back to syntactic heuristics when no schema is available.
Value coercion
Let coerce(text, type) produce the JSON value for a captured scalar text:
type == "string"→ the value istext, verbatim (never JSON-parsed, never unquoted). This is why<path>4</path>for a string parameter is the string"4", and a Windows pathC:\new\tabsurvives intact.typeis a non-string scalar (number/integer/boolean/null) →JSON.parse(text)(so<offset>50</offset>→50,<recursive>true</recursive>→true).typeunknown (no schema) → best-effort JSON coercion: tryJSON.parse(text); on success use the parsed value (number, boolean, null, quoted-string, object, or array); on failure treattextas a literal string. So a bare4becomes the number4,foo.ts(not valid JSON) becomes"foo.ts".
The same coerce applies to attribute values after the surrounding quotes (if any) are stripped — the quotes are XML delimiters only. Hence both spellings below are identical, and both yield the number 4 (not the string "4") when y is untyped/numeric:
<object y=4/> → { "object": { "y": 4 } }
<object y="4"/> → { "object": { "y": 4 } }
Consequence to internalize: under loose/no schema, quoting does not force a string — "4" still coerces to 4. To carry a numeric-looking value as a string, the parameter MUST be string-typed in the schema (then the verbatim rule keeps "4" → "4"). Unquoted attribute values run until whitespace or the closing > / />; spaces around = are tolerated; a bare attribute with no =value (e.g. <call:tool dry_run/>) denotes boolean true.
Scalars and strings
A scalar argument is one element (or one attribute). String values are unquoted and verbatim; non-string scalars are JSON literals:
<call:bash command="ls -la" timeout=30/>
→ bash({ "command": "ls -la", "timeout": 30 }).
Arrays — repeat the element
An array-typed field is expressed by repeating its element; each occurrence contributes one item, in order:
<list>x</list>
<list>y</list>
→ "list": ["x", "y"].
A field the schema types as an array always yields an array, even for a single occurrence — so one <list>x</list> under an array-typed list is ["x"], not "x". When no schema is available the parser falls back to a count heuristic: a name appearing 2+ times among its siblings is an array; a name appearing once is a scalar (so schema typing is the only way to express a one-element array with the heuristic alone). Item values coerce by the array's item type (<ports>80</ports><ports>443</ports> → [80, 443] for a number[]); arrays of objects repeat a nested block (see below). There is no attribute spelling for an array (attributes cannot repeat) — arrays require element form.
Objects — a nested block
An object-typed field opens its own block and follows the same rules recursively: its child elements become its properties, repeated children become arrays, and nested object children open further blocks.
<object>
<list>x</list>
</object>
→ "object": { "list": ["x"] } (with object typed object and list typed array).
An object's scalar sub-fields MAY instead be written as attributes — <object y=4/> is shorthand for <object><y>4</y></object>. Attributes and child elements may be combined on the same object element (attributes for scalars, children for structured sub-fields). An empty object is <object/> or <object></object> → {}.
Recursion
The call body, an object element's body, and an array item's body are all parsed by the identical procedure. Parsing element E (tag = field name F, schema type T):
- Gather
E's attributes → scalar properties viacoerce. - Determine
E's body shape fromT(or, with no schema, from whether the body's first non-whitespace content is a child tag):Tobject → properties from child elements (+ the attributes from step 1).Tarray (item typeTi) → collect all siblings namedF; each occurrence is one item parsed asTi.Tscalar/string → the body is captured text; value =coerce(text, T).
- The call itself is element
Ewith no enclosing key: its attributes + child elements are the arguments object directly (the tool name on<call:NAME>is the recipient, not a key).
Multiple / parallel tool calls
There is no wrapper element around a set of calls. Parallel calls are simply consecutive <call:…> blocks in one assistant turn (separated by whitespace/newlines; interleaved prose is allowed and is ordinary content):
<call:read path="src/a.ts"/>
<call:read path="src/b.ts"/>
A parser returns these as tool_calls[0], tool_calls[1], … in emission order. The host executes them and returns one result per call, in the same order (see correlation, next).
Tool definitions and schema dependence
pi-native does not prescribe how tools are advertised; a host typically lists them as JSON Schema, exactly as the OpenAI / Anthropic / Hermes families do. What pi-native requires is that the parser have access to each tool's parameter schema, because the schema is what disambiguates:
- string (verbatim, unquoted) vs other scalar (JSON) values;
- a one-element array vs a scalar (a single
<list>…</list>); - which body shape (text vs nested members) a non-self-closing element carries;
- whether the inline-body form is legal (first unset parameter is a string).
Without a schema the parser MUST degrade gracefully to the syntactic fallbacks named above (JSON-coerce scalars; repetition-counts for arrays; child-tag presence for object bodies). The fallbacks are lossy at exactly the ambiguous points the schema would resolve, so production hosts SHOULD always supply the schema.
Tool-result correlation
pi-native calls carry no per-call wire id (like GLM and Qwen, unlike Anthropic's toolu_…). Results are therefore correlated to calls positionally, by emission order: the host delivers tool outputs in the same order the <call:…> blocks appeared, using whatever its envelope provides for tool output (a tool/user turn, a Harmony tool message, an Anthropic tool_result block, …). When a transport requires an id (e.g. an OpenAI-compatible bridge), the host synthesizes one and maintains the call↔result mapping itself; the id never appears in the pi-native text.
End-to-end example
A short agent turn exercising all three forms plus nesting. Schemas in play: read(path: string, offset?: number), bash(command: string, timeout?: number), edit(input: string), and a synthetic configure(object: { list: string[]; y?: number }).
I'll inspect the file, run the tests, then apply the fix.
<call:read path="src/server/auth.ts"/>
<call:bash command="bun test src/server/auth.test.ts" timeout=120/>
<call:configure>
<object y=4>
<list>alpha</list>
<list>beta</list>
</object>
</call:configure>
<call:edit>
*** Begin Patch
@@ src/server/auth.ts
- return user;
+ return user ?? null;
*** End Patch
</call:edit>
Parses to four calls, in order:
[
{ "name": "read", "arguments": { "path": "src/server/auth.ts" } },
{ "name": "bash", "arguments": { "command": "bun test src/server/auth.test.ts", "timeout": 120 } },
{ "name": "configure", "arguments": { "object": { "y": 4, "list": ["alpha", "beta"] } } },
{ "name": "edit", "arguments": { "input": "*** Begin Patch\n@@ src/server/auth.ts\n- return user;\n+ return user ?? null;\n*** End Patch" } }
]
Note: timeout=120 and y=4 are JSON numbers (numeric/untyped scalars), path and the list items are verbatim strings (string-typed), object opens a nested block whose y rides as an attribute while list repeats into an array, and the edit body is captured verbatim up to </call:edit> despite containing @@, -/+, and other non-XML text.
Grammar (lenient EBNF)
This is the shape a tolerant parser accepts; it is intentionally looser than XML (mismatched-but-recoverable tails are closed heuristically — see gotchas).
stream ::= ( text | call )*
call ::= self-call | block-call
self-call ::= "<call:" Name attr* ws? "/>"
block-call ::= "<call:" Name attr* ">" call-body "</call:" Name ">"
call-body ::= members | inline-text ; inline-text only if first param is string
members ::= ( ws | element )*
element ::= self-element | block-element
self-element ::= "<" Name attr* ws? "/>" ; object via attrs, or empty value
block-element::= "<" Name attr* ">" ( members | scalar-text ) "</" Name ">"
attr ::= ws Name ( ws? "=" ws? attr-val )? ; bare Name → boolean true
attr-val ::= '"' dq-chars '"' | "'" sq-chars "'" | bareword
Name ::= [A-Za-z_] [A-Za-z0-9_-]*
scalar-text ::= < any chars up to the matching close tag, verbatim >
inline-text ::= < any chars up to "</call:" Name ">", verbatim >
Parsing notes & gotchas
- Schema decides string-vs-JSON. A
string-typed value is verbatim and unquoted; everything else is JSON. With no schema, scalars best-effort JSON-coerce and fall back to string. This is the single most error-prone rule (identical to GLM-4.5's unquoted strings): emitting"San Francisco"for a string parameter yields the literal value including the quote characters. - Quotes are delimiters, not types.
y="4"andy=4both coerce to the number4under loose/no schema. Quoting an attribute never makes it a string; only astringschema type does. - Arrays = repetition; single-element arrays need the schema. Two same-named siblings is unambiguously an array. One occurrence is a scalar under the count heuristic and an array only because the schema says so — a parser without the schema cannot tell
<list>x</list>(scalar) from a one-element array. - Verbatim bodies are delimited by the named closer. The inline body and any
string-typed element body are captured up to their matching</call:NAME>/</KEY>. A body that contains that exact closing sequence truncates early; there is no escaping mechanism. The inline body's risk is minimal because the delimiter includes the tool name (</call:edit>), but a short string-typed element (e.g.<note>…</note>) is more exposed — prefer the inline-body form for any value that might contain markup, or keep such values in the single-string inline payload. - Element form vs inline body. A block call whose body's first non-whitespace content is a child tag matching a known parameter is parsed as element form; otherwise (all-string tool) it is the inline body. A string value that legitimately starts with a
<param>-looking token is the one ambiguity — emit such a tool in element form, or rely on the schema (a tool with structured params is never inline-eligible). - No ids; order is the contract. Calls carry no id; results MUST be returned in call order. Reordering results silently misattributes them.
- Lenient, not strict XML. Do not feed pi-native to an XML parser: tag names contain a colon (
call:read), attribute values may be unquoted, bodies are not entity-encoded, and the stream need not be balanced beyond each call's own open/close. Match the tags as literals (regex / streaming state machine). - Streaming. A stateful parser emits the tool name as soon as
<call:NAMEcloses, then streams attribute/child deltas; for an inline body it streams body text incrementally and holds back any partial trailing</call:until it can decide whether it is the closer. Coercion of a scalar can only finalize at the value's close tag (a partial number/boolean is not yet valid JSON). - Whitespace. Element/inline bodies preserve all whitespace except one leading and one trailing newline that delimit the block. Attribute and indentation whitespace between tags is insignificant.
Sources
pi-native is specified here; it is not derived from a published model template. Its two direct influences are documented in this folder:
- Anthropic attribute XML (
<invoke name="…">/<parameter name="…">, "parsed with regular expressions", not required to be valid XML):anthropic.md. - GLM-4.5 schema-driven, unquoted string values vs JSON non-strings, and positional (id-less) call↔result correlation:
glm-4.5.md.