lace-app-sdk
Custom tools & tool providers
Tools declared in your app run in your app's process. The manifest carries the contract as pure data; the sidecar carries the code. Core never imports your handler — it invokes it over HTTP.
Declare a tool — @tool
from lace_app_sdk.tools import tool
@tool(
tool_id="lookup_ticket",
description_for_model="Returns the ticket row for a subject. Use when the user names a ticket.",
input_schema={"type": "object", "properties": {
"subject": {"type": "string", "description": "Ticket subject, e.g. Heater site 12"}
}, required=["subject"]},
output_schema={"type": "object", "properties": {
"ticket": {"type": "object"}
}},
policy={"read_only": True, "network": False, "writes_app_data": False},
)
def lookup_ticket(args: dict, context: dict) -> dict:
# args is the validated input; context carries tenant, principal, trace ids
return {"ticket": {"subject": args["subject"], "priority": "urgent"}}
# handler signature is always Callable[[dict, dict], dict]
description_for_model is read by the model, not by a human — say what the tool returns and when to choose it over a sibling. input_schema / output_schema are JSON Schemas validated on every call.
Registry — AppToolRegistry keyed by (app_id, tool_id)
The decorator registers an AppToolDescriptor in a process-local AppToolRegistry. The key is (app_id, tool_id) — scoping matters because local dev mounts every app into the API process, but a sidecar hosts one app per process. An unscoped tool_id map would let one app's invoke path resolve to another app's handler. A duplicate (app_id, tool_id) raises immediately.
- Process-local helpers:
registry().register(descriptor, handler),registry().handler(tool_id, app_id=...). - Import scoping helper:
with registering_app("acme.field_intake"): import app.tools— whatLocalAppRuntimeand the sidecar use so authors never hand-writeapp_id. - Ambiguous lookup (same
tool_idunder several apps, noapp_id) returnsNone→ 404, never a guess.
Manifest — contract, not code
# lace_app_manifest.json — tools[] is pure data (no import paths)
{
"tools": [{
"tool_id": "lookup_ticket",
"name": "lookup_ticket",
"description_for_model": "Returns the ticket…",
"input_schema": {...}, "output_schema": {...},
"policy": {"read_only": true, "network": false}
}],
"tool_modules": ["app.tools"] # sidecar imports this to run decorators
}
The descriptor is pure data so core can project it into lace.catalog_definitions without executing app code. tool_modules tells the sidecar which modules to import so the @tool decorators run; without it, the registry stays empty and every catalog row 404s at invoke time — a classic proof-lane failure (testing).
Policy — least privilege by default
| Field | Default | Effect |
|---|---|---|
read_only | true | Must be set false if the tool writes or mutates |
network | false | Opt in for outbound network; forces per-call approval on the catalog row (via lace.apps.tool_catalog_sync) |
writes_app_data | false | Opt in for AppData writes; also forces per-call approval |
Trust tier and permissions are checked before every dispatch, not once at registration — an agent that gains or loses a capability mid-run is subject to the change on the next call. Hosted in lace_runtime.tool_host.
Test it
Unit-test the handler directly (it's a plain function) and run lace-app test for the tool lane — it invokes each handler via the registry with a representative payload and validates the output schema. See testing and src/lace_app_sdk/examples/notes_app.
Next: agents & skills — wire the tool to a capability.