Releases: callstackincubator/appduct
Release list
v0.13.0
0.13.0 (2026-09-28)
- Fix: the iOS SDK connects with the Expo development network inspector enabled. Appduct's
WebSocket connection no longer fails withNSURLErrorDomain -1005when that inspector is on. - Breaking: the CLI commands are now noun-verb, with no aliases.
lsis nowsessions ls,
revokeissessions revoke,linkissessions link,toolsistools ls,tools <name>
istools describe <name>,invokeistools call,eventsisevents tail, and
events --since <cursor>isevents since <cursor>; a removed command's error names its
replacement. - Breaking:
appduct_eventsreturns flat events. Each event is now
{ name, payload, ts, seq, sessionId, alias }instead of{ kind, data }, andlimitnow
defaults to 50. - New: filter events by name.
appduct_eventsacceptsname, a glob such as"cart.*"
(*matches any run of characters), to return only events whose name matches. - Breaking:
appduct_eventstruncates a payload over 4096 bytes by default. A truncated event
comes back as{ name, payloadPreview, truncated: true, payloadBytes }instead of
{ name, payload }; passpayloadMaxBytesto raise or lower the cap. - New:
appduct_eventsandapp.events()reportdroppedandremaining.droppedcounts
events evicted from the retention buffer before you asked, andremainingcounts events still
waiting after the page you got back. - New:
appduct events tailandappduct events sinceaccept--name <glob>and
--payload-max-bytes <n>. They filter events by name and cap payload size; payloads print
whole unless you pass--payload-max-bytes. - New:
appduct events since's trailing cursor line reportsdroppedandremaining.
droppedcounts events evicted from the retention buffer before this pull, andremaining
counts matching events still waiting beyond this page. - New:
app.events()acceptspayloadMaxBytesto cap an event's payload size. Without it every
event keeps its fullpayload; with it, an oversized payload comes back aspayloadPreview/
payloadBytesand you checktruncatedbefore readingpayload. - Breaking:
appduct/client'sAppEventandEventsResulttypes may now be truncated. Code
that annotates a variable or parameter withAppEvent/EventsResultand reads.payload
unconditionally should switch toFullAppEvent/FullEventsResult, whichappduct/clientnow
exports alongsideTruncatedAppEvent. - Breaking:
appduct_wait_for_event'snameis now a glob, andmatchis removed.name
matches the same whole-name globappduct_eventsdoes (*waits for the next event of any
name); passmatchand the call now fails withinvalid_request. - Breaking:
appduct_wait_for_eventtruncates a payload over 4096 bytes by default and reports
dropped. Same shape asappduct_events: a truncated result comes back as
{ name, payloadPreview, truncated: true, payloadBytes, ... }instead of{ name, payload, ... };
passpayloadMaxBytesto raise or lower the cap. - Breaking:
app.waitForEvent()'snameis now a glob,WaitForEventOptions.matchis removed,
and it now acceptspayloadMaxBytes.app.waitForEvent("cart.*")waits for any name matching
the glob andmatchis gone; passpayloadMaxBytesto cap the resolved event's payload the same
wayapp.events()does, otherwise it comes back whole as before. - Docs: the shipped skill covers draining and waiting for app events.
skills/appductnow
shows theappduct_eventsdrain loop (name glob, cursor,remaining,dropped) and the
appduct_wait_for_eventbackground wait.
v0.12.0
0.12.0 (2026-09-24)
- Breaking:
appduct events,appduct_eventsandappduct_wait_for_eventshow only the events
your app posts. Device connections and tool calls no longer appear andkindsis rejected;
to wait for a device, useappduct ls --jsonorappduct_wait_for_session. - Fix: app events are no longer lost after about 128 tool calls. Each device keeps its last
256 app events (eventBufferSize) however many tool calls run in between. - New: the daemon writes its own events to
~/.appduct/events.log. Session, link and tool-call
events land there as JSON lines for debugging Appduct, never your app's events; the file rotates to
events.log.1pasteventsLogMaxBytes(default 10 MiB).
v0.11.1
0.11.1 (2026-09-22)
- Fix: an ungrouped tool reports
group: nullto every reader.appduct_describe_tool
dropped the key instead of reportingnull, so it disagreed withappduct_list_toolsabout
the same tool.ToolDescriptor.groupis
string | undefinedagain — it is the registration type, and registeringnullhas always been
rejected — so the type app authors write against (appduct/client, the React Native SDK's
getRegisteredTools()) no longer admits a value that throws. Code reading atools.listentry
takes the newListedToolDescriptor(orToolsListEntry, which addspolicy), wheregroupis
string | null;appduct/client'stools()now returns those entries. - Fix: the first Appduct command on a clean machine no longer fails with a bare
ENOENT.
Nothing created the state directory before the auto-spawn path wrote into it:~/.appductis
created bystartDaemon, but the spawn-lock anddaemon.log's fd are opened by the parent
process, before the daemon it spawns exists. So with no~/.appductyet, every command that
auto-spawns a daemon —appduct ls,appduct daemon start|status, andappduct mcp, which
died before an MCP client could finishinitialize— failed with
ENOENT: ... open '~/.appduct/daemon.spawn.lock'until someone ranappduct daemon runin the
foreground once. The auto-spawn path now creates the directory (mode0700, same as the daemon
would) before taking the lock.
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 C...
v0.10.0
- New: native SDKs for apps without React Native. The same Appduct core the React Native
package uses is now published on its own:- iOS —
AppductCorevia Swift Package Manager
(.package(url: "https://github.com/callstackincubator/appduct", from: "0.10.0")) or CocoaPods
(pod 'AppductCore', :configurations => ['Debug']). Seepackages/native/ios/README.md. - Android —
com.callstack.appduct:corefor debug builds andcom.callstack.appduct:core-noop
for release builds, on Maven Central. Seepackages/native/android/README.md.
- iOS —
- Breaking (Android): the namespace moved from
com.callstackincubator.appductto
com.callstack.appduct. This covers the Kotlin package, the Android library namespace, and the
AndroidManifest.xmlmeta-data keys.- Expo and autolinked React Native apps: nothing to do. The config plugin and autolinking pick
up the new names on your next prebuild/build. - If you set the meta-data keys by hand: rename them. The old keys are no longer read, so an
app still using them loses its configuration and falls back to the fail-closed defaults.com.callstackincubator.appduct.CLI_PINS→com.callstack.appduct.CLI_PINScom.callstackincubator.appduct.TRUST→com.callstack.appduct.TRUSTcom.callstackincubator.appduct.ALLOW_PRIVATE_LAN_ONLY→com.callstack.appduct.ALLOW_PRIVATE_LAN_ONLY
- If you import Appduct's Kotlin classes directly: update the imports to
com.callstack.appduct.
- Expo and autolinked React Native apps: nothing to do. The config plugin and autolinking pick
appduct doctorrecognises both the new and the pre-0.10.0 Android namespace, so a release gate
still detects Appduct in apps built against 0.9.0 or earlier.- Fix: resolved Swift strict-concurrency warnings in the iOS core.
- Fix: CLI and MCP messages say "an Appduct" instead of "a Appduct".
- Docs: the README is scoped to React Native, with new guidance on the MCP and CLI ways to use
Appduct with an agent.
What's Changed
- feat: framework-free native core with plain iOS/Android entry points (#48) by @V3RON in #49
- chore(deps): bump actions/setup-java from 6.0.0 to 6.0.1 by @dependabot[bot] in #50
- docs: make the README easier to skim and clearer on platform support by @V3RON in #51
- ci: stop installing the removed legacy 'tools' SDK package by @V3RON in #53
- Rename Cordierite to Appduct by @V3RON in #52
- docs: scope the README to React Native until native SDKs are released by @V3RON in #54
- fix: use "an Appduct" instead of "a Appduct" by @V3RON in #56
- docs: explain the MCP and CLI ways to use Appduct with an agent by @V3RON in #55
- Fix the Swift strict-concurrency warnings and gate them with an iOS CI build by @V3RON in #59
- build: CocoaPods and SwiftPM by @V3RON in #58
- Publish core/core-noop to Maven Central as com.callstack.appduct by @V3RON in #60
Full Changelog: v0.8.0...v0.10.0
v0.8.0
What's Changed
- fix: stop cordierite_connect and delivered deep links from silently stranding a session by @V3RON in #21
- fix: exclude ios/CordieriteTests from npm package by @V3RON in #22
- chore(ci): use iPhone 17 for iOS tests by @V3RON in #46
- ci: run the iOS job on macos-latest by @V3RON in #47
- fix(react-native): register useCordieriteTool once per mount and route calls through a ref (#28) by @V3RON in #35
- feat(react-native): accept paired and raw JSON Schema tool schemas; dev error when no exporter (#27) by @V3RON in #40
- fix(mcp): never emit a non-object outputSchema and guard structuredContent (#26) by @V3RON in #42
- feat: carry per-tool timeoutMs to the daemon and size MCP/CLI transport timeouts (#25) by @V3RON in #45
- feat(cli): detect daemon/CLI version drift and restart the daemon when safe (#30) by @V3RON in #44
- feat(daemon): audit log retention and daemon.log rotation (#32) by @V3RON in #43
- feat: elicitation consent channel for policy "prompt" (issue #10 channel 1) by @V3RON in #24
- feat(cli): experimental --open ios-device delivery via xcrun devicectl (#31) by @V3RON in #36
- feat(cli): cordierite init, scheme discovery from app.json, and --scheme/CORDIERITE_SCHEME for mcp (#29) by @V3RON in #41
- chore(deps): bump actions/setup-java from 5.7.0 to 6.0.0 by @dependabot[bot] in #23
- docs: five-minute READMEs, hardening moved to docs/, markdown link check (#33) by @V3RON in #37
- chore(deps): bump gradle/actions/setup-gradle from 67621b124fd2e251c5e8a0e6e3b91318f2287669 to 9c971963bec38e04b3d30dcc455b5382be2fdbfb by @dependabot[bot] in #4
New Contributors
- @dependabot[bot] made their first contribution in #23
Full Changelog: v0.7.0...v0.8.0
v0.7.0
- Fix: a terminal daemon rejection (1008) now ends the session instead of retrying until
grace.onSocketLostpreviously treated only close code 1000 as terminal, so an
unretryable rejection —unknown_sessionafter a daemon restart,invalid_resume_token, a
session revoked or expired while offline — kept the client inreconnectingfor up to
grace_s(600s default) before itssessionChange: lostlistener ever fired. Every
daemon-side rejection of this kind closes with 1008, so 1008 is now terminal wholesale rather
than matched by reason string; transport-level closes (1011, 1001, 1006) stay retryable. A
failed resume's close code now travels with the rejection via
CordieriteHandshakeClosedErrorso it isn't thrown away before reachingonSocketLost. - Fix:
restoreSession()is now a first-class export, reachable without going through
installCordieriteDeepLinkBootstrapor thecordieriteClientproxy. An app that drives
bootstrap itself (custom deep-link routing, QR scanning, a manualconnect()) had nothing
reading the native lease, so every Metro reload dropped a session native could still have
resumed. Exported from the root and./noopentries andCordierePublicApi. - Breaking:
requirePrivateIpis removed;/autois now the only install path.
allowPrivateLanOnlywas already native build config
(CordieriteAllowPrivateLanOnlyin Info.plist / the Android manifest) enforced by native
connect()on both platforms — the JSrequirePrivateIpoption could only narrow what
native already allowed, so setting it tofalsewithout also setting the native key did
nothing. The deep-link handler now readsallowPrivateLanOnlyfrom the same
getConstants()path native enforces from, failing closed when it can't be read.
installCordieriteDeepLinkBootstrapandInstallCordieriteDeepLinkBootstrapOptionsare gone;
require("@cordierite/react-native/auto")is the only way to install the bootstrap listener
now. Seepackages/react-native/README.mdfor the current surface.
v0.6.0
- New: tool call cancellation (
tools.cancelRPC,tool_cancelwire frame,AbortSignalin the RN handler context, SIGINT cancels an in-flightcordierite invoke). - New: daemon-side event retention and a pull surface (
events.since), pluscordierite_events/cordierite_wait_for_eventMCP tools. - New: minimal
"prompt"policy value, gated via MCPanthropic/requiresUserInteraction(fails closed for every other caller). - New:
cordierite/clientprogrammatic API for test runners (connect,link/waitForSession,app.call,app.events/app.waitForEvent).
See CHANGELOG.md for full details.
v0.5.1
v0.5.0
What's Changed
- refactor: migrate shared and react-native packages to cordierite scope by @V3RON in #1
- Improving resilience of the tool by @V3RON in #2
- feat!: zero-config dev mode and default-inert release builds by @V3RON in #3
New Contributors
Full Changelog: https://github.com/callstackincubator/cordierite/commits/v0.5.0