Skip to main content

The shape of it

External functionality should have typed interfaces and explicit permissions. That’s tool — a distinct primitive from action, not a variant of it:
1

Calling a tool runs the body immediately

No prepare → verify → commit. A tool isn’t necessarily world-changing — a search, a lookup — so there’s no reason to make its caller thread through the full action lifecycle just to get typed, permissioned access to it.
2

The capability check happens once, at the call site

If requires is given, it’s checked against the caller’s policy scope the instant the call happens — the same Environment.get_capability lookup action’s .verify()/.commit() use — and denial raises SensePolicyError before the body ever runs.
3

Every call is audited

Tool calls land in the same audit_log() action events do, tagged tool instead of reversible/irreversible.

requires is optional, same as action

A tool with no requires clause is just a documented, typed interface — unrestricted, exactly like a plain fn. The value of declaring it as tool rather than fn is purely documentation and audit-log visibility until you add a capability.

Closing the “what an action’s body calls” gap

An action’s requires only gates entering that action — code the body goes on to call has no capability check of its own, unless that code is itself a tool.
Wrapping what an action’s body calls in a tool gets that call checked too, without any new enforcement mechanism — the same policy-scope lookup already does the work.

Describing a tool so a model can use it

The optional trailing string is backward compatible — every tool keeps working undescribed exactly as before — but it becomes required the moment a tool is handed to ask_with_tools (SenseRuntimeError naming the tool if missing): that’s the whole point of a real model choosing which tool to call rather than Sense code deciding by hand — the description is what it decides from. It has to stay on the same source line as the rest of the signature; Sense’s indentation-based lexer has no bracket-style newline suppression spanning past it.

Concurrency: async tool

Every example above is sync — the call blocks until the body returns. async tool makes that optional:
The arity/capability/argument-type checks stay synchronous — a denied capability or a bad argument still raises immediately at the call site, exactly like a sync tool, so SensePolicyError never needs await() to surface. Only once those pass does the body run, on its own daemon thread, with the call returning a Future immediately. await(future) blocks until it’s done and either returns the body’s return value or re-raises whatever it raised; the returns type check (if declared) happens at await time, not call time, since the value doesn’t exist yet when the call returns.
See Agents#concurrency for why this is opt-in rather than every call becoming non-blocking by default (the function-coloring problem), and for the same unmitigated concurrency caveats — no synchronization primitives, and no true parallelism for CPU-bound work under the GIL, only I/O-bound work actually overlaps.

What this doesn’t do

Not supported. There’s no .commit() boundary to check it at — a tool runs synchronously the instant it’s called.
A tool isn’t declared as world-changing. If it needs to be, wrap it in an action instead.
requires stays a bare dotted path, same as action today — no cost/risk/rate-limit attached. Tracked on the Roadmap.

Continue

Actions & policy

The full prepare → verify → commit lifecycle a tool deliberately skips.

Tool-Calling

Letting a real model decide which tool to invoke.

Agents

The same opt-in concurrency model, for a long-running autonomous body.