The shape of it
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
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
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
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:
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
requires approval
requires approval
Not supported. There’s no
.commit() boundary to check it at — a tool
runs synchronously the instant it’s called.reversible / irreversible classification
reversible / irreversible classification
A tool isn’t declared as world-changing. If it needs to be, wrap it in
an
action instead.Capability dimensions beyond a name
Capability dimensions beyond a name
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.

