Skip to content
LACE
  • v0.1 Current
  • Python
  • TypeScript Soon

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

pythonapp/tools.py
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 — what LocalAppRuntime and the sidecar use so authors never hand-write app_id.
  • Ambiguous lookup (same tool_id under several apps, no app_id) returns None → 404, never a guess.

Manifest — contract, not code

jsonlace_app_manifest.json
# 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

FieldDefaultEffect
read_onlytrueMust be set false if the tool writes or mutates
networkfalseOpt in for outbound network; forces per-call approval on the catalog row (via lace.apps.tool_catalog_sync)
writes_app_datafalseOpt 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.