Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ Call Appduct directly from Swift or Kotlin. You get the same tools, deep links,

There are two ways to connect an agent. Pick whichever fits how your agent works.

**Over MCP.** Add Appduct to your agent's MCP config — Claude Code, Cursor, or any other MCP client. Your app's tools show up next to the agent's own, and the connection tools explain themselves, so there's nothing else to install:
**Over MCP.** Add Appduct to your agent's MCP config — Claude Code, Cursor, or any other MCP client. Your app's tools aren't registered as MCP tools of their own: the agent reaches them through `appduct_list_tools`, `appduct_describe_tool` and `appduct_call_tool`, so a registry of hundreds costs the client three fixed tool definitions. Those tools and the connection tools explain themselves, so there's nothing else to install:

```json
{
Expand Down
35 changes: 25 additions & 10 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -553,8 +553,20 @@ they cannot drift. First match wins:
the state directory in use — those are *global* config, and matching them here would apply them
one tier above their own)
4. `scheme` in the state directory's `config.json`
5. `<cwd>/app.json`'s `expo.scheme` (a string, or the first entry of an array — the same
normalization `app.plugin.js` applies; no walk-up)
5. a static-file probe of the project in `<cwd>` (no walk-up), in this order — `scheme.ts`'s
`discoverStaticProjectScheme` owns the whole step:
a. `<cwd>/app.json`'s `expo.scheme` (a string, or the first entry of an array — the same
normalization `app.plugin.js` applies)
b. Android: `app/build.gradle(.kts)`'s `appductScheme` manifest placeholder, then
`app/src/main/AndroidManifest.xml`'s first `<data android:scheme>` in a `VIEW` intent
filter
c. iOS: any `Info.plist` up to two levels below the app root (excluding
`Pods`/`build`/`node_modules`/`DerivedData`) for the first `CFBundleURLSchemes` entry,
then xcodegen's `project.yml` for the same key

`native-scheme.ts` owns 5b/5c, parses every file statically (no `plutil`, no `xcodebuild`,
no Gradle evaluation), and refuses to guess when two of its probes resolve *different*
schemes — it throws a usage error naming both sources instead.
6. otherwise an error naming every location above

The project `.appduct/config.json` carries a second key alongside `scheme` since issue #63:
Expand Down Expand Up @@ -595,9 +607,11 @@ never guesses one from a discovered value the way it never guesses `scheme` from
native probe (§10's discussion of `discoverNativeScheme`).

Re-running it is always safe: it keeps the scheme (and any recorded app id) already recorded and
only *notes* a scheme divergence when `app.json` has come to declare a different one — a command
only *notes* a scheme divergence when discovery (`app.json` or a native project file) has come to
declare a different one — a command
documented as safe to re-run must not start failing because a scheme was renamed. `--scheme
<different>` needs `--force` to replace a recorded value, `--force` alone re-adopts `app.json`'s;
<different>` needs `--force` to replace a recorded value, `--force` alone re-adopts whatever
discovery currently finds;
the same "replacing needs `--force`" rule applies to `--ios-app-id`/`--android-app-id`, which have
no discovery tier to re-adopt on `--force` alone. `--force` merges rather than truncating. Note
the inverse of the rule above: a project `.appduct/` is committed, so `--state-dir` must never
Expand Down Expand Up @@ -760,12 +774,13 @@ deviations):
the daemon's own reason — surfaced on the unified `stateChange` event's `reason` — rather
than retried for the remainder of the grace window. Transport-level closes stay
retryable, including `1011 send_failed` and `1001 daemon_shutdown`: the daemon may well
be back before grace expires. The `sessionChange` event itself carries only
`{ sessionId, alias }`, both `null` once the session is gone; it no longer distinguishes
a claim from a resume from a loss (that categorization was JS-tracked state that no
longer exists on this side of the bridge) — a listener that needs the departing session's
id/alias keeps the most recent non-null event, and reads the reason off the paired
`stateChange` event.
be back before grace expires. The `sessionChange` event mirrors native's `onSessionChange`
exactly: `{ type, sessionId, alias, reason? }`, where `type` is `"claimed"` / `"resumed"` /
`"lost"`, `sessionId`/`alias` go `null` once the session is gone, and `reason` is set only
for `"lost"` (`revoked`, `grace_expired`, `closed_by_app`, or a terminal close reason from
the daemon — PROTOCOL.md §7). That mirrors the paired `stateChange` event's `reason`, which
says the same thing from the state machine's perspective rather than the session's; a
listener that needs the departing session's id/alias keeps the most recent non-null event.
- Native's own `handleUrl(url)` decodes the v2 bootstrap payload, checks expiry and the
private-IP policy (`allowPrivateLanOnly`, read once from the same manifest/plist key
`resolveTrustedPins` uses), and decides whether the link outranks a session already held
Expand Down
5 changes: 3 additions & 2 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,8 +392,9 @@ not as the mechanism that keeps a destructive tool out of reach of a hostile one
need to confirm the log is actually landing on disk (e.g. under a read-only or full
filesystem).
- **Inclusion defaults to dev builds only — not a compiled-in build-type check.** iOS
restricts CocoaPods linking to the `Debug` configuration; Android swaps in a no-op
`AppductPackage` for `release`. Both are real per-variant decisions, not a
restricts CocoaPods linking to the `Debug` configuration; on Android `AppductPackage`
always compiles, and the `release` variant's compile classpath gets the vendored no-op
core (`android/core-noop`) in place of the real one. Both are real per-variant decisions, not a
`debuggable`/`#if DEBUG` gate compiled into every variant, and neither quietly depends on
a custom build-type/configuration name being spelled `debug`/`Debug`.

Expand Down
6 changes: 3 additions & 3 deletions docs/TOOLS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Registering tools

How the app side of Appduct publishes tools: the schema forms it accepts, what the
`useAppductTool` hook actually re-registers, the shape MCP requires, and how long a call
may run. The five-minute version lives in the
`useAppductTool` hook actually re-registers, the shape a published schema has to take, and
how long a call may run. The five-minute version lives in the
[package README](../packages/react-native/README.md#4-define-tools-in-app-startup-code); keeping a
destructive tool out of a build variant is [its own
section](./SECURITY.md#gating-a-tool-by-build-variant) of the security model.
Expand Down Expand Up @@ -168,4 +168,4 @@ useAppductTool(

That deadline is enforced end to end: the app aborts the handler's `signal` at it, and it also travels to the daemon as the descriptor's `timeout_ms`, so an agent calling the tool over MCP (or `appduct invoke` with no `--timeout`) gets the same budget instead of a `tool_timeout` at 10 seconds.

The SDK clamps the value to `[1_000, 600_000]` ms before either timer is set, so the handler's abort timer and the daemon's call deadline are always the same number (a value outside that range is clamped with a dev warning). A caller that passes its own timeout (`appduct invoke --timeout`, `app.call(name, args, { timeoutMs })`) can only **shorten** the deadline, never extend it past this one — the app aborts the handler at its own timer regardless, so for a tool that declares nothing, a caller asking for 60 seconds still gets the app's 10-second default. `createAppductClient`'s `defaultToolTimeoutMs` changes only that app-side fallback for tools that declare nothing; it is deliberately not sent to the daemon, so declare `timeoutMs` per tool when the host needs to know.
The SDK clamps the value to `[1_000, 600_000]` ms before either timer is set, so the handler's abort timer and the daemon's call deadline are always the same number (a value outside that range is clamped with a dev warning). A caller that passes its own timeout (`appduct invoke --timeout`, `app.call(name, args, { timeoutMs })`) can only **shorten** the deadline, never extend it past this one — the app aborts the handler at its own timer regardless, so for a tool that declares nothing, a caller asking for 60 seconds still gets the app's 10-second default. That app-side fallback is fixed natively (`AppductClient`'s own `defaultToolTimeoutMs`, `APPDUCT_DEFAULT_TOOL_TIMEOUT_MS`) and JS cannot override it — `createAppductClient`'s options are empty, and the TurboModule spec has no channel for it. Declare `timeoutMs` per tool when a call needs longer than the default.
7 changes: 5 additions & 2 deletions packages/appduct/src/cli/create-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ export const createCli = () => {
.command("init", "Set up the current app directory: write .appduct/config.json and print the MCP snippet.")
.option(
"--scheme <scheme>",
"Deep-link URI scheme to write. Only this flag and <cwd>/app.json are consulted — not " +
"Deep-link URI scheme to write. Only this flag and static project files in <cwd> are " +
"consulted (app.json's \"expo.scheme\", then Android app/build.gradle(.kts) and " +
"app/src/main/AndroidManifest.xml, then iOS Info.plist and project.yml) — not " +
"APPDUCT_SCHEME, and no walk-up: init decides what to write here, so it never bakes " +
"an ambient value into a committed file.",
)
Expand All @@ -46,7 +48,8 @@ export const createCli = () => {
.option("--qr", "Also render the deep link as a terminal QR code.")
.option(
"--scheme <scheme>",
"Deep-link URI scheme (also: APPDUCT_SCHEME; default: app.json's \"expo.scheme\").",
"Deep-link URI scheme (also: APPDUCT_SCHEME; default: .appduct/config.json, then " +
"app.json's \"expo.scheme\", then the Android/iOS project files in <cwd>).",
)
.option(
"--open <target>",
Expand Down
12 changes: 8 additions & 4 deletions packages/react-native/metro.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@

export interface WithAppductOptions {
/**
* Mirrors the `@appduct/react-native` config plugin's `include` option. `true` (default)
* leaves Metro's module resolution untouched. `false` redirects every specifier this package
* exposes as a real JS entry point to `@appduct/react-native/noop`.
* `true` leaves Metro's module resolution untouched; `false` redirects every specifier this
* package exposes as a real JS entry point to `@appduct/react-native/noop`. Omitted, it
* follows `APPDUCT_ENABLED` (via `autolink-env.js`, where unset counts as enabled) — the same
* variable that drives native autolinking, so one pipeline variable strips both surfaces. The
* config plugin's `include` option is gone; an explicit value here is for apps keying the JS
* strip off their own predicate.
*/
include?: boolean;
}

/**
* Wraps a Metro config so that, when `options.include` is `false`, imports of
* Wraps a Metro config so that, when Appduct is excluded (`options.include` is `false`, or it is
* omitted and `APPDUCT_ENABLED` is falsy), imports of
* `@appduct/react-native` (and any other entry point this package exports) resolve to the
* inert `/noop` entry instead. Chains to `config.resolver.resolveRequest` if already set, rather
* than replacing it -- call this last, after anything else that sets `resolveRequest`. See
Expand Down
2 changes: 2 additions & 0 deletions playground/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ one in `~/.appduct`.

### 2. Build and run the dev client

From the `playground` directory (`expo` is a dependency of this app, not of the monorepo root):

```sh
pnpm exec expo run:ios
# or
Expand Down
15 changes: 10 additions & 5 deletions skills/appduct/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ There is no `--session-id` flag in v2 — use the positional selector instead.
## Establish a session

If no session is active yet, mint a bootstrap link. **Run this from the app's root
directory and it needs no configuration at all** — the deep-link scheme is read from
`app.json`'s `expo.scheme`:
directory and it needs no configuration at all** — the deep-link scheme is read from the
project's own files: `app.json`'s `expo.scheme`, or, for an app without one, the Android
`app/build.gradle(.kts)` / `app/src/main/AndroidManifest.xml` and the iOS `Info.plist` /
`project.yml`:

```bash
appduct link --json
Expand All @@ -72,8 +74,10 @@ directory, or the project uses a dynamic `app.config.js` (which is never execute
`APPDUCT_SCHEME=myapp` does the same for a whole shell, and `appduct init` records
it once in the project (see **Setup** below). Full order: `--scheme` → `APPDUCT_SCHEME`
→ the nearest `.appduct/config.json` walking up from the working directory → the state
dir's `config.json` → `<cwd>/app.json`. If none of them has one, the error names every
location it tried.
dir's `config.json` → the static project files in `<cwd>` (`app.json`, then the Android
`build.gradle`/`AndroidManifest.xml`, then the iOS `Info.plist`/`project.yml`). If none of
them has one, the error names every location it tried. Two native probes that disagree are
an error too, never a guess.

From `link`'s JSON output, use:

Expand Down Expand Up @@ -231,7 +235,8 @@ when a group itself outgrows a screen. Each part of a group uses tool-name chara
under `--json` are JSON on stderr, not bare text.
- `appduct init`, run once in an app root, records the scheme in
`.appduct/config.json` and prints the MCP server entry to paste. Re-running it is
always safe (it keeps the recorded scheme; `--force` re-adopts `app.json`'s), it never
always safe (it keeps the recorded scheme; `--force` re-adopts whatever the project files
currently declare), it never
generates keys, and it never touches daemon state.
- `appduct keygen` is only for **hardening** (rotating the host key, or provisioning
one in CI ahead of a release build) — the daemon auto-generates a key on first start,
Expand Down
17 changes: 13 additions & 4 deletions skills/appduct/references/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
Use this file when the task is to add Appduct to a new React Native project.

The development path needs **no keys, no pins and no config file**. The daemon generates
its own host key on first start, the deep-link scheme is discovered from `app.json`, and a
its own host key on first start, the deep-link scheme is discovered from the project's own
files (`app.json`, or the Android/iOS project files — see below), and a
debug build trusts the pin carried in the link itself. Everything under **Hardening** below
is for builds that leave your machine — do not do it as part of a first-time setup.

Expand Down Expand Up @@ -32,7 +33,7 @@ is for builds that leave your machine — do not do it as part of a first-time s
(`docs/SECURITY.md`, "Trust modes").
7. Optional: use `addAppductListener("error", ...)` to observe bootstrap parse
failures, connect failures, or socket errors — one unified channel for all of them.
6. Advanced: use `getAppductState()` / `addAppductListener("stateChange", ...)` for
8. Advanced: use `getAppductState()` / `addAppductListener("stateChange", ...)` for
manual connection-state UI.
9. Production builds that shouldn't ship Appduct at all should be built with
`APPDUCT_ENABLED=0` rather than gated by a runtime flag. That variable drops the
Expand Down Expand Up @@ -74,8 +75,16 @@ If the project uses a dynamic `app.config.js` / `app.config.ts`, discovery does
2. Run the normal native dependency installation steps for the project.
3. Configure URL schemes / intent filters so bootstrap links (`{scheme}:///?appduct=…`)
open your app.
4. Run `appduct init --scheme <scheme>` in the project root — there is no `app.json`
`expo.scheme` to discover, so name the scheme you configured in step 3. Add
4. Run `appduct init` in the project root. With no `app.json` `expo.scheme`, discovery
falls back to static native project files, all relative to the directory you run it in
(never a walk-up): `app/build.gradle(.kts)`'s `appductScheme` manifest placeholder, then
`app/src/main/AndroidManifest.xml`'s first `<data android:scheme>` in a `VIEW` intent
filter, then any `Info.plist` up to two levels down for the first `CFBundleURLSchemes`
entry, then xcodegen's `project.yml`. From a bare React Native root the iOS plist probe
reaches `ios/<App>/Info.plist`, but the Android ones expect an Android project root
(`android/`), so pass `appduct init --scheme <scheme>` with the scheme you configured in
step 3 whenever discovery comes up empty. It also refuses to guess when two probes
resolve different schemes — `--scheme` is the answer there too. Add
`--android-app-id <applicationId> --ios-app-id <bundle-id>` so device delivery works
without an `--app-id` on every call.
5. Add the optional private-LAN-only setting only if the project wants that restriction.
Expand Down
Loading