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 allowedValues over free strings whenever the input is one of a fixed set — the model can't misspell an enum.
  • Keep required minimal. 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