Define tool domains
A tool domain is the unit you hand the model: a manifest that says what the domain is, tool definitions the model reads, and one executor that runs every call. Your first agent builds a two-tool domain end to end — this page goes deeper: multi-tool executors, schema design, and the outcome contract.
The manifest
DomainManifest carries four fields:
| Field | What it does |
|---|---|
id |
the domain's namespace — prefix every tool id with it |
version |
yours to manage; shown alongside the domain when the model lists it |
capabilities |
honest declarations of what the domain can do (next section) |
summary |
one line the model reads when it discovers the domain |
Qualify tool ids with the domain — domain.tool_name, like notes.search —
so provenance stays clear in logs and conversation history. The
agentkit domain id and the agentkit. tool prefix belong to the built-in
meta-tools; registration validates both — see
scope tools for the full validation list.
Write the summary for the model, not the changelog. When an agent has many
domains, the model picks which one to activate by reading these lines.
Declare capabilities honestly
DomainCapability is an OptionSet:
| Capability | Declare it when |
|---|---|
.readOnly |
tools only query state |
.mutating |
tools modify app state |
.networking |
tools reach the network |
.paid |
calls cost real money |
.destructive |
tools delete or irreversibly change data |
Combine them freely — [.mutating, .paid] for an export domain that renders
and bills, [.mutating, .destructive] for one that deletes.
Capabilities are declarations, not sandboxes — nothing stops a domain that
lies, so their value is exactly as good as your honesty. They surface where
decisions get made: the model sees them when it lists domains
(scope tools), your guard and confirmation
policy keys on the levels you declared
(guard the dangerous calls), and successful
calls from a .mutating domain refresh dependent cached context
(inject live state).
Design the schema
ToolSchema is a closed enum — six cases, and every provider understands all
of them, so a schema that compiles is a schema that ships:
| Case | Use for |
|---|---|
.object(properties:required:additionalProperties:) |
tool parameters — the root of every tool schema |
.array(items:) |
homogeneous lists |
.string(allowedValues:) |
text; pass allowedValues to make it an enum |
.integer |
whole numbers |
.number |
any numeric value |
.boolean |
flags |
Objects are closed by default: additionalProperties defaults to false,
so the properties you declare are the whole contract.
Schema design is prompt design — the model fills what you describe:
- Describe every property. The
ToolSchemaProperty(schema:description:)description is the only place to state units, formats, and defaults ("seconds", "ISO 8601", "defaults to 10"). - Prefer
allowedValuesover free strings whenever the input is one of a fixed set — the model can't misspell an enum. - Keep
requiredminimal. Mark a property required only when the executor can't proceed without it. - Flat beats nested. Models fill flat objects more reliably; nest only when the grouping itself carries meaning.
One executor, many tools
One executor serves the whole domain. Route on call.name and keep one
private method per tool — the switch stays readable at twenty tools:
struct NotesDomain: ToolDomain {
let manifest = DomainManifest(
id: "notes",
version: "1.0",
capabilities: [.mutating, .destructive],
summary: "Search, create, and delete the user's notes"
)
let tools: [ToolDefinition] = [
ToolDefinition(
id: "notes.search",
description: "Search notes by text. Returns matching note ids and titles.",
parameters: .object(
properties: [
"query": ToolSchemaProperty(schema: .string(), description: "Text to search for"),
"limit": ToolSchemaProperty(schema: .integer, description: "Maximum results (defaults to 10)"),
],
required: ["query"]
)
),
ToolDefinition(
id: "notes.create",
description: "Create a note in a folder.",
parameters: .object(
properties: [
"title": ToolSchemaProperty(schema: .string(), description: "Note title"),
"folder": ToolSchemaProperty(
schema: .string(allowedValues: ["inbox", "archive"]),
description: "Destination folder"
),
"pinned": ToolSchemaProperty(schema: .boolean, description: "Pin the note after creating it"),
"tags": ToolSchemaProperty(schema: .array(items: .string()), description: "Tags to apply"),
],
required: ["title", "folder"]
)
),
ToolDefinition(
id: "notes.delete",
description: "Delete a note permanently. Locked notes cannot be deleted.",
parameters: .object(
properties: [
"note_id": ToolSchemaProperty(schema: .string(), description: "Note identifier"),
],
required: ["note_id"]
)
),
]
let executor: any ToolExecutor
init(store: NotesStore) {
self.executor = NotesExecutor(store: store)
}
}
struct NotesExecutor: ToolExecutor {
let store: NotesStore
func execute(_ call: ToolCall, revision: UInt64?) async throws -> ToolOutcome {
switch call.name {
case "notes.search": return await search(call)
case "notes.create": return await create(call)
case "notes.delete": return await delete(call)
default:
return .failed(ToolErrorPayload(message: "unknown tool \(call.name)"))
}
}
private func search(_ call: ToolCall) async -> ToolOutcome {
guard let query = call.arguments["query"]?.stringValue else {
return .failed(ToolErrorPayload(message: "missing query"))
}
let limit = call.arguments["limit"]?.intValue ?? 10
let hits = await store.search(query, limit: limit)
return .success(ToolResultPayload(content: [
.text("\(hits.count) notes match '\(query)'"),
.json(.array(hits.map { .object(["id": .string($0.id), "title": .string($0.title)]) })),
]))
}
private func create(_ call: ToolCall) async -> ToolOutcome {
guard let title = call.arguments["title"]?.stringValue,
let folder = call.arguments["folder"]?.stringValue else {
return .failed(ToolErrorPayload(message: "missing title or folder"))
}
let pinned = call.arguments["pinned"]?.boolValue ?? false
let tags = call.arguments["tags"]?.arrayValue?.compactMap(\.stringValue) ?? []
let id = await store.create(title: title, folder: folder, pinned: pinned, tags: tags)
return .success(ToolResultPayload(
content: [.text("Created '\(title)' in \(folder)")],
affectedEntities: [EntityRef(domain: "notes", id: id)]
))
}
private func delete(_ call: ToolCall) async -> ToolOutcome {
guard let noteId = call.arguments["note_id"]?.stringValue else {
return .failed(ToolErrorPayload(message: "missing note_id"))
}
if await store.isLocked(noteId) {
return .denied(reason: "note \(noteId) is locked")
}
await store.delete(noteId)
return .success(ToolResultPayload(
content: [.text("Deleted \(noteId)")],
affectedEntities: [EntityRef(domain: "notes", id: noteId)]
))
}
}
NotesStore stands in for whatever owns your state. The executor holds it
and runs every call against it — the model never touches your state directly.
Return failures as outcomes; throw only when the whole turn should abort. A thrown error ends the turn — see when it fails.
Read the arguments
call.arguments is a JSONValue. Every typed accessor returns an optional —
nil on a type mismatch — so guard and return a .failed outcome with a
message instead of force-unwrapping:
| Accessor | Returns |
|---|---|
.stringValue |
String? |
.intValue |
Int? — whole-number JSON only |
.doubleValue |
Double? — accepts integers too |
.boolValue |
Bool? |
.arrayValue |
[JSONValue]? |
.objectValue |
[String: JSONValue]? |
subscript(key) |
JSONValue? — member of an object, chainable |
Models routinely send 5 where you expect 5.0. Read numeric parameters
with .doubleValue, which accepts both; .intValue is strict and returns
nil for 5.0. Chain subscripts for nested objects:
call.arguments["options"]?["quality"]?.stringValue.
Return outcomes the model can act on
The executor returns ToolOutcome — failures are values the model sees and
reasons about, not exceptions. Four cases, and exactly what the model reads
for each:
| Outcome | The model sees |
|---|---|
.success(ToolResultPayload) |
the payload's content, as returned |
.denied(reason:) |
Tool denied: <reason> |
.failed(ToolErrorPayload) |
Tool failed: <message> — or Tool failed (retryable): <message> when isRetryable is set — flagged as an error |
.conflict(ConflictPayload) |
Conflict: <message>, plus State delta: <summary> when set |
Only .failed marks the result as an error on the wire. Denials and
conflicts are feedback — the model adjusts course instead of treating the
tool as broken.
Make the text actionable. "missing clip_id" invites a retry with the right
arguments; "error 4011" invites a hallucinated apology. On
ToolErrorPayload(message:isRetryable:), the message is the part the model
reacts to; setting isRetryable adds a (retryable) marker to that text, so
the model knows it can safely try the call again.
.conflict is for optimistic concurrency — the revision parameter your
executor receives carries the expected state version to compare against.
Undo every turn covers revision checks and the
context refresh that follows a conflict.
Rich results
ToolResultPayload carries more than text. Five content kinds, and the text
form each takes for a model that only reads text:
| Content | Text form |
|---|---|
.text("…") |
as written |
.json(value) |
compact JSON |
.image(.data(data, mimeType:)) |
Image (<mime>, <count> bytes) |
.image(.fileURL(url)) |
Image at <filename> |
.file(url, mimeType:) |
File: <filename> (<mime>) |
.entity(EntityRef) |
Entity: <domain>.<id> |
Pair .text with .json — a sentence for grounding, structured data for
precision — the way notes.search does above. List what a mutation touched
in affectedEntities; inject live state explains
the context refresh that triggers.
A result can also opt out of your chat transcript:
return .success(ToolResultPayload(
content: [.text("internal bookkeeping updated")],
visibility: .hiddenFromUI
))
The default is .standard. A .hiddenFromUI result still reaches the model
in full — the flag rides on the payload so your UI can keep plumbing results
out of the conversation view. The built-in meta-tools mark their own results
hidden for exactly this reason.
Next
- Scope tools. Stage discovery. — what each agent sees, and how tools surface mid-conversation.
- Guard the dangerous calls — policy on top of the capabilities you declared.
- Undo every turn — transactions, revisions, and conflicts.