v0.11.0
-
Docs: designing tools for agents.
docs/TOOLS.mdgains a "Designing tools for agents"
section (name by intent, annotate, pair mutations with observers, declareoutputSchema,
describe parameters, coarse over fine,timeoutMs, no tools that wait on a person), linked from
the React Native, iOS and Android READMEs and the website. The Appduct skill is split into a short
SKILL.md(the CLI loop, chaining calls in one shell invocation, errors) plus on-demand
references for the CLI, writing tools and setup. The playground tools now follow the rules:
reset_counteris alsoidempotentHint,throwing_toolisreadOnlyHint, and descriptions
name their side effects. -
New: tool groups. A tool can declare an optional
group— a top-level group ("cart")
or one subgroup below it ("checkout/payment"); each part matches the tool-name pattern
[a-zA-Z0-9_-]{1,64}. Set it withregisterTool/useAppductTool'sgroupoption (a change
to it re-registers the tool), withcreateToolGroup("cart")to bind one group for a whole
feature module, or with thegroupparameter of the Swift and Kotlinregistercalls. An
invalid group invalidates the registry snapshot exactly like an invalidtimeout_ms, so all
three SDKs reject it at registration — an explicitnullincluded, so an ungrouped tool omits
the option rather than passingnull. A daemon that predates groups ignores the field. -
New:
appduct tools --group <name>andappduct tools --groups.--group checkoutlists
thecheckoutgroup and all its subgroups (checkout/paymentlists just that subgroup) and
combines with--filter/--limit/--offset;--groupslists only the groups and their tool
counts. Without--group, a registry with groups is listed under group headings, and a
truncated listing's footer names the top-level groups to narrow to.tools.listgains a
groupparam (applied beforetotaland paging) and agroupssummary of the whole registry
on every result, soappduct tools --jsonnow returns{ tools, total, groups }. Every entry
carries agroup,nullfor an ungrouped tool — the same value thegroupssummary uses for
its own ungrouped row, so one test answers "ungrouped" in either half of the result. -
New: groups over MCP.
appduct_list_toolstakes agroup(same matching as--group),
shows each tool'sgroup, and returns thegroupssummary on every result, so an agent can
see an app's areas and list one of them.appduct_describe_toolincludes the tool'sgroup. -
Fixed (iOS): a tool name with a trailing newline (
"tool\n") is now rejected, matching
@appduct/sharedand Android. The Swift core's name check accepted it because ICU's$also
matches before a final line terminator; the daemon would then have rejected the snapshot. -
Breaking (MCP): the app's tools are no longer listed as MCP tools. An agent reaches them
through three built-ins that mirror the CLI:appduct_list_tools(one-line signatures and
each tool's policy, withfilter/limit/offset, likeappduct tools),
appduct_describe_tool(one tool's full schema, likeappduct tools <name>) and
appduct_call_tool({ selector?, name, args?, timeoutMs? }, likeappduct invoke).
tools/listis now a fixed set of built-ins, so an app with hundreds of tools adds three
definitions to an agent's context, not hundreds.appduct_list_toolsreturns 50 tools at a time
unless givenlimit.selectortakes a session alias or id; a call is routed by session id, so
it fails withunknown_sessionrather than reaching a new device that took over a departed
device's alias. Unknown parameters are rejected (invalid_request).timeoutMscan only
shorten the tool's own deadline, since the app stops a tool at its declared timeout; a longer
one, or one outside 1000–600000, is rejected rather than clamped. What goes away:- Calling an app tool by its own name through
tools/call. It now returnstool_not_found,
pointing atappduct_list_toolsandappduct_call_tool. <alias>__<name>namespacing. With several devices connected, passselector(the
session alias or id) instead.notifications/tools/list_changed, and thelistChangedcapability.- MCP-level
outputSchemaenforcement and schema degradation: schemas reach the agent as
data throughappduct_describe_tool, exactly as registered, whatever their root type. The
React Native SDK no longer warns about non-object output schemas. - MCP client permission rules that named individual app tools (for example
mcp__appduct__seed_cart) no longer match anything; the client's permission now covers
appduct_call_toolas a whole, so "always allow" there approves every app tool. To keep a
person approving destructive calls, setpolicy.destructiveto"prompt"(it covers tools
annotateddestructiveHint: true)."prompt"-policy
consent itself is unchanged: it is asked per call, via elicitation. - The React Native SDK's input-schema warning now fires only for a root
typethat rules out
an object (z.string(),z.array(...)), not for unions or intersections of objects. @appduct/sharedno longer exportsisObjectRootedSchema.
- Calling an app tool by its own name through
-
Breaking (MCP):
"prompt"-policy consent is elicitation-only. The Claude Code-specific
fallback is gone:tools/listno longer emits_meta["anthropic/requiresUserInteraction"], and
the MCP server no longer sendsconsent: "client". A"prompt"tool called from an MCP client
that doesn't declare theelicitationcapability is now denied withpolicy_denied(reason
no_consent_channel), the same as the CLI. Current Claude Code declares elicitation, so it gets
the elicitation prompt instead; only a client that relied on the flag without supporting
elicitation loses access. To fix that, use a client that supports elicitation, or set the tool's
policy to"allow"inconfig.json— which removes the gate for every caller, including the
CLI — and restart the daemon (appduct daemon stop), sinceconfig.jsonis read once at daemon
start. The restart disconnects every device, which then has to link again, and a running
appduct mcploses its daemon connection, so restart the MCP server in your client too.- A daemon with this change that receives
consent: "client"from an older MCP server treats it
as no consent, so the call is denied and audited asno_consent_channel. @appduct/shared:ToolsCallParams.consentand the audit record'sconsentnarrow to
"elicitation". New audit records never carry"client"; existing audit files may.
- A daemon with this change that receives
-
config.json'swssPortaccepts0, meaning "bind an OS-assigned port". The pinned-wss
listener takes whatever ephemeral port the OS hands it, and everything that reports or advertises
the port —daemon.status'swssPort, a minted link'sendpoint.port, and so the deep link and
QR code composed from it — carries the bound port rather than the configured0. This lets
several daemons (separate state dirs) coexist on one machine without an operator hand-picking a
port for each. Every other value must still be a port number in1..65535; the default is
unchanged at8443. -
Fixed: a zombie daemon process no longer blocks pidfile takeover.
process.kill(pid, 0)
succeeds for an exited-but-unreaped process, so a daemon that was killed after its parent CLI had
exited could keep its pidfile looking live — in containers whose PID 1 does not reap, for the
life of the container, leaving every later command reporting a daemon that was already dead. The
liveness probe now also reads/proc/<pid>/statuson Linux and treatsState: Zas dead;
everywhere/procis absent or unreadable the previous behaviour is unchanged. -
Breaking (CLI):
--jsonoutput is compact by default. Everyappduct <command> --json
invocation used to pretty-print its JSON with 2-space indentation; it now prints it on a single
line (JSON.stringify, no whitespace). Any JSON parser is unaffected. A script that greps or
diffs the indented text directly is not — pass the new--prettyflag to restore the old
indentation. -
Breaking (CLI): the
metablock (command,timestamp,duration_ms) is no longer emitted
by default, in either human or--jsonoutput. Pass the new--verboseflag to restore it —
the trailingMetalines in human mode, themetafield on the--jsonenvelope. -
New:
--prettyand--verboseglobal flags, alongside--jsonand--no-color. See the
[appductREADMEhttps://github.com/callstackincubator/appduct/blob/main/packages/appduct/README.md) for the full description of each. -
Breaking (CLI):
appduct tools --jsonfor a listing now returns{ tools, total }instead
of a bare array. The single-tool form (appduct tools <selector> <name>) is unchanged — it still
returns the bare tool descriptor. -
New: a signature-based
toolslisting, with--filter/--limit/--offset. The human
listing now shows one call signature (name(params) -> result) plus a one-line description per
tool instead of a bare name/description table, andappduct toolsgains--filter <text>to
narrow by name/description, and--limit <n>/--offset <n>to page through a large registry —
making it cheap to readappduct toolsagainst an app that registers hundreds of tools. See the
[appductREADMEhttps://github.com/callstackincubator/appduct/blob/main/packages/appduct/README.md)'s "appduct tools: a signature per tool" section
for details. -
The
appductagent skill now defaults its example commands to plain-text output, adding
--jsononly where a script (not the agent itself) will parse the result. -
Fixed: a bad argument to
tools,invoke,revokeorevents(a missing<tool>, too
many positionals,--limit 0) crashed the CLI with a stack trace instead of printing a usage
error with exit code 64. A numeric flag given without a value (--limit,--limit -1,
--since,--ttl,--timeout) is now a usage error; it used to be read as1. -
Breaking:
--open android/target: "android"now require the installed app's id —
including an Android deviceappduct_connectauto-detects, which now fails with
invalid_requestuntil an app id is configured.
Previouslyadb shell am startwas invoked with an implicit intent (no-p); when more than
one installed app declared the deep-link scheme, Android showed an "Open with" chooser and
am startstill reported success, soappduct_wait_for_session/the CLI blocked its whole
timeout with nothing explaining why (issue #63). Delivery now names the package explicitly
(am start ... -p <app-id>) and requires an app id rather than falling back — for both
androidand the experimentalios-devicetarget.- Migration: run
appduct init --scheme <s> --android-app-id <id> --ios-app-id <id>in
your app root once (writesappId.android/appId.iosinto.appduct/config.json), or pass
--app-id <id>onappduct link/appIdon the MCPappduct_connecttool /appIdon
mintLink/appduct/client'slink()per call.ios-simneeds none of this — it is a usage
error to pass one there. - Removed:
--bundle-id(CLI),bundleId(MCPappduct_connect),bundleId
(mintLink/appduct/client'slink()), andconfig.json'siosBundleId— all replaced by
--app-id/appId/appId.<platform>above, which now also coversandroid. There is no
deprecation shim: a leftoveriosBundleIdinconfig.jsonis silently ignored (an unknown
key just warns, andloadConfig'swarndefaults to a no-op), so the delivery-time error
above is the only signal that it needs replacing.
- Migration: run
Full history: CHANGELOG.md