Metadata-Version: 2.5
Name: papaya-agent-client
Version: 0.9.0
Summary: Configurable local runner for Papaya agent-client jobs
Author: Papaya
Keywords: agents,automation,claude-code-plugin,local-runner,mcp,papaya
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development
Requires-Python: >=3.11
Requires-Dist: fastmcp==3.2.4
Requires-Dist: httpx>=0.27
Requires-Dist: websockets>=14.0
Description-Content-Type: text/markdown

# Papaya Agent Client

`papaya-agent` connects local agents to Papaya. The same client supports two
workflows:

- Papaya job dispatch: Papaya sends a job to this machine, the client runs your
  configured local command, and the result is posted back to Papaya.
- MCP capabilities: Claude, Codex, or another MCP host can call Papaya tools as
  one or more configured Papaya agents.

The published package name is `papaya-agent-client`. The command users run is
always `papaya-agent`.

## Install

```bash
npx papaya-agent connect
```

That is the whole setup. The npm package `papaya-agent` is a small shim around
the real client, which is and stays the Python package `papaya-agent-client`:
it finds `uv` on your `PATH` or downloads a pinned, checksum verified copy into
its own directory, then runs `papaya-agent` through it. After a successful
`connect` it installs the client properly (`uv tool install`) so `papaya-agent`
is on your `PATH` for good.

`connect` opens your browser, you click Approve, the terminal asks which agent
to connect as, and Claude Code has the Papaya tools next time you open it.
Nothing is copied, pasted, or exported.

If you would rather manage the Python package yourself:

```bash
uv tool install papaya-agent-client
papaya-agent connect
```

Either way, `papaya-agent` has to stay on your `PATH` afterwards: the Claude
Code plugin runs it both for its hooks and for the `papaya` MCP server itself.
`papaya-agent hooks doctor` tells you whether it is there.

Two environment variables steer the npm shim, and neither is needed normally:
`PAPAYA_UV` points it at a `uv` binary of your choosing, and
`PAPAYA_AGENT_SOURCE` replaces the package spec it installs from (a local path,
a git URL, or any other spec `uv` accepts).

For local development from this repository:

```bash
uv sync
uv run papaya-agent --help
```

The examples below use `uv run papaya-agent` when they are meant for a source
checkout. If you installed the package, drop `uv run`.

## Command Overview

| Command | Purpose |
| --- | --- |
| `papaya-agent` | Print help. It does not start job listening. |
| `papaya-agent login` | Authenticate, save the user session, and configure local agent connections. |
| `papaya-agent listen` | Start the event listener (falls back to the legacy dispatched-job listener when there is no `connect` connection). |
| `papaya-agent workspaces` | List workspace IDs, slugs, and names for MCP/config setup. |
| `papaya-agent connect` | One-step harness setup: sign in through the browser, pick the agent here, mint a scoped token, verify it, and install or emit Claude Code, Cursor, or Codex config. |
| `papaya-agent context` | Print the connected agent's durable context (persona, rules, memories); used by the plugin hooks. |
| `papaya-agent hooks stop` | Claude Code `Stop`/`SessionEnd` hook: renew this session's subject reservations, or release them at session end. |
| `papaya-agent hooks doctor` | Show which Claude Code hooks are registered and the tail of `~/.papaya-agent/hook.log`. |
| `papaya-agent guard` | Claude Code `PreToolUse` hook: enforce the agent's checkable rules. Exit 2 denies. |
| `papaya-agent agents add` | Add or rotate local agent connections. |
| `papaya-agent agents edit` | Edit local command settings for a configured agent. |
| `papaya-agent agents remove` | Remove local agent configs. |
| `papaya-agent logout` | Clear the saved user session and local agent configs. |
| `papaya-agent mcp passthrough` | The stdio MCP server the harnesses run: a proxy of the hosted Papaya server that reads the token file itself. |
| `papaya-agent mcp ...` | Run or print MCP server configuration. |

## Endpoints

By default, the client uses production and does not prompt users for URLs:

```text
HTTP: https://api.trypapaya.ai
WS:   wss://api.trypapaya.ai
```

For local Papaya development, set:

```bash
export LOCAL_PAPAYA=true
```

That switches both HTTP and WebSocket traffic to localhost:

```text
HTTP: http://localhost:5173
WS:   ws://localhost:5173
```

## First-Time Setup

Run:

```bash
uv run papaya-agent login
```

The setup flow asks for your Papaya email and password, then shows the Papaya
agents this account can manage. Your password is never stored. For each selected
agent, the client asks for:

- A display name for this local connection.
- The command to run when Papaya dispatches a job.
- The working directory, allowed working directories, and timeout.

Endpoint selection is automatic. Remote agent-client connections are created
only after the full setup flow is complete. The saved config lives at
`~/.papaya-agent/config.json` with `0600` permissions and includes the saved
user refresh token plus one scoped `pagc_...` token per configured agent.

`login` only authenticates and configures local connections. It does not open
WebSockets, drain queued jobs, or start listening for dispatched jobs.

## Connect A Coding Harness

`papaya-agent connect` is the fast path for Claude Code, Cursor, or Codex. It
signs you in through the browser, lets you pick the agent here in the terminal,
verifies the minted `pagc_...` connection token with `GET /api/v1/whoami`, and
sets the harness up:

```bash
uv run papaya-agent connect                      # Claude Code (default)
uv run papaya-agent connect --harness cursor
uv run papaya-agent connect --harness codex
uv run papaya-agent connect --name "Laptop"
```

The browser flow (CONTRACT §11.3) is a loopback redirect with PKCE. `connect`
binds `http://127.0.0.1:<ephemeral port>/callback`, asks the backend where its
sign-in page is, opens your browser there, and waits up to five minutes:

```text
Waiting for you to approve in the browser...
https://app.trypapaya.ai/cli/authorize?state=...&code_challenge=...
Signed in as Sarah Wolf (sarah@example.com)
Workspace: Papaya HQ (papaya)
Agent: Engineering Agent @eng (engineer)
Connected as Engineering Agent (@eng) in Papaya HQ.
```

The browser authenticates **you**, and nothing else: the workspace and the agent
are chosen in the terminal afterwards, from the same lists `--workspace` and
`--agent` filter. One of either is auto-picked with a line saying which; more
than one gets a numbered prompt. The link is printed on its own line as well as
opened, so a browser on another machine works too (`--no-browser` skips the
open). `--name` sets the connection name (the machine's hostname otherwise).

If you deny the request in the browser, or five minutes pass with no answer,
`connect` prints one line and exits 1. A callback carrying somebody else's
`state` is answered and ignored, and the flow keeps waiting.

The run ends with the plugin installed and one last line:

```text
Connected as Engineering Agent in Papaya HQ. Open Claude Code in any repository, the papaya tools are ready.
```

Flags:

- `--device` signs in with a short code approved in the Papaya app instead
  (CONTRACT §6), which is what to use over SSH or on a headless machine. See
  [Device-code sign-in](#device-code-sign-in). A backend with no
  `/auth/cli/start` route yet falls back to it automatically, with a one-line
  notice, and so does a machine that cannot bind a loopback port.
- `--password` signs in with email and password instead. A backend with neither
  browser nor device sign-in falls back to this automatically.
- `--no-install` skips the Claude Code plugin install, which `connect`
  otherwise does for you. Cursor and Codex still wait for `--install`, which
  merges the `papaya` server into `~/.cursor/mcp.json` or runs `codex mcp add`.
- `--print` prints only the `mcpServers` JSON for that harness on stdout
  (status lines go to stderr), and installs nothing.
- `--via-proxy` and `--read-only` emit the legacy subset proxy instead; see
  [Hosted server or local proxy](#hosted-server-or-local-proxy).

Every harness gets the same `papaya` server: `papaya-agent mcp passthrough`
over stdio (CONTRACT §11.4), a proxy of the hosted Papaya MCP server at
`https://api.trypapaya.ai/api/v1/mcp/`. It reads the token out of
`~/.papaya-agent/connect-token` (0600) itself, so **no environment variable has
to be set and there is nothing to add to a shell profile**. The token lives
only in `~/.papaya-agent/connect-token` and `~/.papaya-agent/config.json` (both
0600); `connect` never writes it into `.mcp.json`, `settings.json`,
`~/.cursor/mcp.json`, or any other shared file.

```json
{
  "mcpServers": {
    "papaya": {
      "type": "stdio",
      "command": "papaya-agent",
      "args": ["mcp", "passthrough"]
    }
  }
}
```

Because the harness runs the CLI, `papaya-agent` must be on the `PATH` the
harness starts with. `papaya-agent hooks doctor` reports whether it is, and the
plugin's own entry honours `$PAPAYA_AGENT_BIN` when it is not.

The pass-through connects once before it serves, which does two jobs: a token
the hosted server rejects fails there, on your terminal, with `Run papaya-agent
connect` on stderr and exit 1, and the remote's `initialize` instructions (your
agent's per-connection context) are copied onto the proxy so the model still
sees them. Tools, resources and prompts are forwarded dynamically, so a tool
Papaya adds server-side appears without a client release. `--agent <ref>` serves
a configured agent's stored token instead of the connect token.

### Device-code sign-in

`papaya-agent connect --device` prints a link and a short code, then waits:

```text
Open https://app.trypapaya.ai/device?user_code=ABCD-EFGH and approve code ABCD-EFGH
https://app.trypapaya.ai/device
ABCD-EFGH
Waiting for approval in Papaya...
```

Open that page, sign in, and choose the workspace and agent there; the CLI polls
until you approve and then stores the token. The plain link and the code are on
their own lines so they are easy to copy, and `connect` also tries to open the
browser unless `--no-browser`. Polling honours the server's `interval`, backs
off when the server says `slow_down`, and gives up when the code expires; an
expired code, a denied request, or a code that was already used prints one line
and exits 1.

### Hosted server or local proxy

Two ways to give a harness the `papaya` MCP server, and the default is the right
one almost always:

| Mode | Command | When to use it |
| --- | --- | --- |
| Pass-through (default) | `papaya-agent connect` | Normal setup. The full hosted tool set, proxied over stdio, with the token read from `~/.papaya-agent/`. |
| Legacy local proxy | `papaya-agent connect --via-proxy` | You need `acting_as` on every write result, so the harness can see which Papaya agent it just spoke as. |
| Legacy local proxy, read-only | `papaya-agent connect --via-proxy --read-only` | The session must be able to read Papaya but must not be able to write to it. Write tools are not exposed at all. |

`--via-proxy` is a documented legacy mode: it is the hand-built subset proxy,
not the pass-through, and `connect` no longer emits it by default. It runs a
different stdio command:

```json
{
  "mcpServers": {
    "papaya": {
      "type": "stdio",
      "command": "papaya-agent",
      "args": ["mcp", "--agent", "scout", "--read-only"]
    }
  }
}
```

Claude Code and Codex also get a `claude mcp add` / `codex mcp add` line (which
`--install` runs for you); Cursor gets the entry merged into
`~/.cursor/mcp.json`. `--read-only` only applies together with `--via-proxy`,
because read-only enforcement lives in the proxy. The proxy resolves its agent
from what `connect` stored, so `papaya-agent mcp --agent <ref>` works on a
machine that only ever ran `connect`, and it reads the token out of
`~/.papaya-agent/connect-token` itself, like the pass-through does. The
trade-off is the tool set, which is a hand-maintained subset rather than
whatever the hosted server publishes (see
[Add MCP Capabilities](#add-mcp-capabilities)).

### The bundled Claude Code plugin

The package ships a Claude Code plugin at `papaya_agent_client/plugin/`
(`python -c "from papaya_agent_client.connect import plugin_root; print(plugin_root())"`
prints the installed path). It contains:

- `.claude-plugin/plugin.json` and a local `marketplace.json`, so
  `claude plugin marketplace add <plugin_root>` then
  `claude plugin install papaya@papaya-agent-client` installs it. For one
  session, `claude --plugin-dir <plugin_root>` works without installing.
- `.mcp.json`: the `papaya` server, which runs
  `${PAPAYA_AGENT_BIN:-papaya-agent} mcp passthrough` over stdio. No URL and no
  token appear in it, because the pass-through reads both from
  `~/.papaya-agent/` at run time.
- `hooks/hooks.json` wiring seven events to five bash scripts (see
  [Claude Code hooks](#claude-code-hooks) below). Every one of them is
  fail-soft: if `papaya-agent` is missing, nothing is connected, or Papaya is
  unreachable, they exit 0 and change nothing.
- `skills/papaya-connect/SKILL.md`: when to run `connect`, how to verify with
  `whoami`, how to read the full context with `get_my_agent_context`
  (`papaya://me/context`), and the memory rules: `save_agent_memory` for things
  about the agent itself, `propose_memory` for the workspace or people.
- `.cursor-plugin/`, `.codex-plugin/`, and `PAPAYA.md` for Cursor and Codex,
  which do not run the hooks and instead load `PAPAYA.md` as instructions.

Installing the plugin is all it takes for the `papaya` tools to appear: the
server is a command, not a URL with a header, so nothing has to be in Claude
Code's environment. The one requirement is that `papaya-agent` resolves on the
`PATH` Claude Code started with (or that `$PAPAYA_AGENT_BIN` points at it). The
hook's `$CLAUDE_ENV_FILE` export still publishes the token and session id for
Bash tool calls inside the session.

### Claude Code hooks

An interactive Claude Code session has no wrapper process around it, so the
guarantees `papaya-agent listen` gives a non-interactive run (reserve, work,
release) are wired through Claude Code's own hooks instead.

| Event | Script | What it does |
| --- | --- | --- |
| `SessionStart` | `papaya-session-start.sh` | Mints this session's reservation session id, appends `PAPAYA_AGENT_TOKEN`, `PAPAYA_WORKSPACE_ID` and `PAPAYA_SESSION_ID` to `$CLAUDE_ENV_FILE`, injects the agent's durable context as `additionalContext`, and declares this connection's runtime and capabilities. The injected block ends with the session id and what to pass it to. |
| `PreCompact` | `papaya-precompact.sh` | Refreshes and re-injects the context so it survives compaction. |
| `UserPromptSubmit` | `papaya-prompt.sh` | When the bundle's `content_hash` has moved since this session last saw it, injects a short header plus the new context. Silent otherwise, which is almost every prompt. |
| `PreToolUse` | `papaya-guard.sh` | Enforces the checkable part of the agent's rules before a Papaya MCP tool runs. Exit 2 blocks the call and tells the model why. |
| `PostToolUse` | `papaya-post-tool.sh` | Renews the same reservations in the middle of a long turn, at most once every 180 seconds. |
| `Stop` | `papaya-stop.sh Stop` | Renews every subject reservation this session holds, at the TTL the server granted. |
| `SessionEnd` | `papaya-stop.sh SessionEnd` | Releases them. |

`Stop` and `SessionEnd` are not the same event, and the difference is the
reason `Stop` does not release. Claude Code fires `Stop` whenever the model
finishes responding, which includes pausing to ask the person a question in the
middle of a task; releasing there would hand the subject to somebody else while
the work is half done. So `Stop` is the interactive equivalent of the listener's
renew tick, and `SessionEnd`, which fires once when the session is actually
over, is the release. A renew that comes back 409 means the lease was lost to
somebody else: the hook says so and releases nothing, because the subject is no
longer ours to release. A renew that comes back `renewed: false` means the lease
was lost too (a person released it, or it expired) and the renew has just
re-acquired it; the hook releases it again and reports it, so a session never
re-takes what somebody handed back. All three renew and release events touch
only reservations whose `holder.session_id` is this session's, so a listener
sharing the connection keeps its own.

`Stop` alone is not enough for a long turn: it fires when the model stops
talking, and one tool-heavy turn can run past a 15 minute lease without ever
reaching it. `PostToolUse` renews mid-turn, which is far too often to call
Papaya every time, so the bash hook is gated on a stamp file:
`~/.papaya-agent/renew-<claude session id>.stamp` (0600) is rewritten every time
the CLI renews, and while it is younger than 180 seconds the hook exits without
starting Python at all. A session holding nothing still stamps, so an idle
session costs at most one call every 180 seconds; `SessionEnd` deletes the stamp.

A lost lease is the one thing these hooks tell the *model* rather than the log.
Claude Code shows the model nothing from a hook that exits 0, so a session whose
subject was taken away would carry on working it. Instead `Stop` and
`PostToolUse` print one line of `{"decision": "block", "reason": "..."}` on
stdout when a renew came back 409 or `renewed: false`; the reason names each
subject, says how it was lost, and tells the model to stop, tell the person, and
post, edit or push nothing more for it. `SessionEnd` never blocks (the session is
over and there is no model left to tell), a payload with `stop_hook_active` set
is only logged (blocking a turn that a block already resumed would loop), and a
renew that succeeds prints nothing at all.

The session hooks are tied together by Claude Code's own `session_id`,
which arrives on each hook's stdin. `SessionStart` records
`{session_id, content_hash, started_at}` under that key in
`~/.papaya-agent/context-cache/<connection_id>.json`, and the later hooks read
it back. The rest of the hook payload is barely read: `tool_name` and
`tool_input` for `PreToolUse`, and `stop_hook_active` for the two renew events.

Rule enforcement is deliberately narrow. Only a rule with an `enforce` object
is mechanical:

- `{"deny_tool": "<glob>"}` blocks the tool outright.
- `{"require_substring": {"tool": "<glob>", "substring": "<str>"}}` blocks it
  unless the serialised tool input contains that substring.

The glob is matched against the tool name with the `mcp__papaya__` prefix
stripped, so rules are written as `post_message`, not
`mcp__papaya__post_message`. The `PreToolUse` matcher is `mcp__papaya__.*`, so
rules never speak for the harness's own file or shell tools. Free-text rules
with `enforce: null` are for the model to follow, not for the hook to check.

Enforcement fails **open**. No cache, no rules, an unreadable payload or any
exception at all allows the call and appends a line to `~/.papaya-agent/hook.log`.
A rule that quietly stops being enforced is a bad day; a hook that bricks an
interactive session is a worse one.

```bash
uv run papaya-agent hooks doctor   # what is registered, and the last 20 log lines
```

Cursor and Codex do not run hooks. Those harnesses rely on `papaya-agent listen`
to renew and release subjects when a listener is driving them, and otherwise on
lease expiry or an explicit `release_subject`: Codex has no lifecycle hooks at
all, so nothing ends its leases for it.

### What a connection declares about itself

Every connection tells Papaya what is on the other end of it, through
`PATCH /api/v1/workspaces/{ws}/polyweave-agents/me/connection`. That is how the
app can tell an interactive session from a listener, and how the backend knows
whether to enforce the agent's rules itself.

| Runtime | `runtime_kind` | Capabilities | Who enforces `enforce` rules |
| --- | --- | --- | --- |
| Claude Code (the plugin, at `SessionStart`) | `claude-code` | `heartbeat: true`, `interception: "advisory"` | the `PreToolUse` hook, locally |
| `connect --harness claude` | `claude-code` | `heartbeat: true`, `interception: "advisory"` | the `PreToolUse` hook, locally |
| `connect --harness codex` | `codex` | `heartbeat: false`, `interception: "enforcing"` | Papaya, server side |
| `connect --harness cursor` | `cursor` | `heartbeat: false`, `interception: "enforcing"` | Papaya, server side |

For an `enforcing` connection Papaya evaluates the agent's `enforce` rules
before it runs a tool call that arrives on that connection's token, and a
blocked call comes back as the tool error
`Blocked by agent rule [<id>]: <text>`. An `advisory` connection is checked by
nothing on the server, because the `PreToolUse` hook has already checked it.

`SessionStart` declares at most once per connection per 24 hours (a
`declared_at` stamp in the per-connection cache) and `connect` declares once per
run. Both are fail-soft: `SessionStart` logs and carries on, `connect` prints one
warning line and still finishes.

### Durable context from the CLI

```bash
uv run papaya-agent context            # composed block, at most 6000 characters
uv run papaya-agent context --json     # {"content_hash", "changed", "block", ...}
uv run papaya-agent context --refresh  # bypass the cache
```

`context` reads the connection recorded by the latest `connect`, calls
`GET /api/v1/workspaces/{ws}/polyweave-agents/me/context` with the agent token
(never with `include_sensitive=true`), and caches the result per connection at
`~/.papaya-agent/context-cache/<connection_id>.json` (0600, directory 0700) for
60 seconds keyed by the backend `content_hash`. An unchanged hash reuses the
cached rendering; a changed hash overwrites it; if Papaya is unreachable the
last cached block is served.

Known gap: the backend doc `docs/agent-api/papaya-agent-client.md` predates
`connect` and `context` and needs an update.

## Workspace IDs

List workspaces for the saved user session:

```bash
uv run papaya-agent workspaces
```

Default output is a column-aligned table:

```text
ID                                    SLUG          NAME
8f3c4b9d-7a21-4c8e-9b3f-2a1c5d4e6f70  acme          Acme Inc
b2a1f5e8-c4d6-4831-9b87-3a2d1c5e8f90  side-project  Side Project
```

Use JSON-lines output for scripts:

```bash
uv run papaya-agent workspaces --json
```

```json
{"id":"8f3c4b9d-7a21-4c8e-9b3f-2a1c5d4e6f70","slug":"acme","name":"Acme Inc"}
```

Use quiet output when only IDs are needed:

```bash
uv run papaya-agent workspaces -q
```

The workspace endpoint does not currently return the caller's workspace role,
so role is intentionally not shown.

## Listen For Events

```bash
uv run papaya-agent listen
```

When this machine has a connection from `papaya-agent connect`, `listen` runs the
connected-agent event loop. Every turn has the same shape, whatever harness you
point it at:

```text
pull events → apply the playbook → reserve the subject → run your command
            → renew the lease while it runs → release on exit → advance the cursor
```

HTTP polling is the contract; the WebSocket only carries a `papaya.events_available`
doorbell that says "go pull", so the listener reacts immediately but never depends
on the socket being up. The same process still serves legacy dispatched jobs (see
[Listen For Dispatched Jobs](#listen-for-dispatched-jobs)); if there is no `connect`
connection, `listen` falls back to the legacy loop unchanged.

| Flag | Default | Meaning |
| --- | --- | --- |
| `--run '<command>'` | the packaged `papaya-claude-runner.sh` | Harness command; `{prompt}` is replaced with the generated instruction. |
| `--poll-interval N` | `20` (env `PAPAYA_POLL_INTERVAL`) | Seconds between event pulls. |
| `--max-concurrent N` | `1` | Subjects worked on at once. |
| `--runtime-kind KIND` | `custom` | Label reported to Papaya (`claude-code`, `codex`, …). |
| `--resume` | off | Advertise that this runtime can resume a previous session. |
| `--match REGEX` | none | Only act on events matching; repeatable, and every pattern must match. |
| `--exclude REGEX` | none | Never act on events matching; repeatable. |
| `--explain-match FILE` | none | Print the canonical string and the verdict for a saved envelope, then exit. |

Claude Code and Codex are two command strings, not two code paths:

```bash
# Claude Code (the default)
uv run papaya-agent listen --runtime-kind claude-code

# Codex; see scripts/papaya-codex-runner.sh
uv run papaya-agent listen --runtime-kind codex \
  --run './scripts/papaya-codex-runner.sh {prompt}'
```

At startup the listener reports its capabilities to Papaya (`delivery`,
`heartbeat`, `resume`, `interception`, `max_concurrent_subjects`). That call is
fail-soft: if it does not go through, the listener logs it and carries on.

The packaged Claude runner reads one environment variable of its own:

| Variable | Default | Meaning |
| --- | --- | --- |
| `PAPAYA_CLAUDE_PERMISSION_MODE` | `bypassPermissions` | The `--permission-mode` the runner passes to `claude`. Set it to `acceptEdits`, `plan`, or `default` to run the listener's sessions under a stricter mode. |

```bash
PAPAYA_CLAUDE_PERMISSION_MODE=acceptEdits uv run papaya-agent listen
```

### Which Events Are This Machine's

One Papaya agent can have a listener on every machine you own, and only one of
them has the backend checked out. `--match` and `--exclude` are how a listener
says what it can actually do. Both are Python regexes (`re.search`,
case-insensitive), both repeat, and both are tested against one canonical string
per event:

```text
"<kind> <subject> " + json.dumps(payload, sort_keys=True)
```

```bash
# The bug machine: only work items whose kind is "bug".
uv run papaya-agent listen --match 'work_item.*"kind": "bug"'

# The backend machine: anything except the frontend.
uv run papaya-agent listen --exclude 'frontend'
```

Every `--match` must hit and no `--exclude` may, so patterns narrow rather than
widen, and an event that hits both is refused: saying "never this" is the
stronger statement. An event that does not pass is treated exactly like an
`ignore`: the cursor advances, nothing is reserved, and one line is logged.

```text
[listen] event=4512 not mine (--match/--exclude): work_item.assigned work_item:5d0c...
```

The filter is local to the machine. The server never sees it, so it never routes
around it, and only events the playbook already resolved to `act` reach it. To
test a pattern without running anything, save an envelope (any run leaves one at
`$PAPAYA_EVENT_FILE`) and ask:

```bash
uv run papaya-agent listen --explain-match ./envelope.json --match '"kind": "bug"'
# Canonical: work_item.assigned work_item:5d0c... {"note": null, "work_item": {"kind": "bug", ...}}
# Verdict: run
```

It exits `0` when the event would be worked on this machine and `1` when it
would not, so it fits in a script. A malformed pattern is rejected before
anything connects.

### The Playbook

Each event kind maps to `act`, `acknowledge`, or `ignore`. The mapping comes from
the agent's resolved event playbook on `me/context`, edited in the Papaya app and
merged by the server over the defaults below. **No model call is made to decide.**

| Event kind | Reservable | Default action |
| --- | --- | --- |
| `mention.you` | yes | act |
| `work_item.assigned` | yes | act |
| `work_item.comment` | yes | act |
| `dm.received` | yes | act |
| `approval.waiting` | yes | act |
| `work_item.status_changed` | no | acknowledge |
| `subject.reserved` | no | ignore |
| `subject.released` | no | ignore |

`acknowledge` advances the cursor and does nothing else. `ignore` does the same
and does not even log it as interesting. An unknown event kind is ignored, and
`act` on a non-reservable event is downgraded to `acknowledge`, because the
listener never reserves a subject the server did not offer a lease on.

### Subject Reservations

A subject (`work_item:<uuid>`, `thread:<uuid>`, `dm:<uuid>`, `proposal:<uuid>`) is
the unit of work. Only one session works a subject at a time:

- Before running, the listener reserves the subject. If another session already
  holds it, the holder is logged, the event is skipped, and the cursor moves on.
  No retry storm, no jitter.
- While the harness runs, the lease is renewed every `granted_ttl_seconds / 3`.
  Network failures are retried on the next tick; the server-side TTL is the
  safety net, so a flaky network never kills good work.
- If a renewal comes back "held by someone else" (409), the lease was lost: the
  harness is killed and the subject is *not* released, because this session no
  longer owns it.
- If a renewal comes back `renewed: false`, the lease was also lost, and this
  call has just taken the subject again. The harness is killed and the subject
  *is* released, because the accidental re-acquire made this session the holder.
- On any exit (clean, crash, or SIGTERM) the subject is released.

**Losing a race is not the end of it.** A subject this listener wanted but lost
(the reserve came back 409) is remembered along with the event that asked for it,
up to 200 subjects, oldest forgotten first. When a `subject.released` arrives for
one of them, the listener bids again and runs the *original* event, not the
release notice:

```text
[listen] event=4530 re-bid on work_item:5d0c... after expired
```

| Release reason | What it means | Re-bid |
| --- | --- | --- |
| `expired` | The holder's lease ran out. | yes |
| `revoked` | The holder's connection was revoked. | yes |
| `declined` | The holder said the work was not theirs. | yes |
| `holder` | The holder finished. | no |
| `human` | A person took the subject away. | no |

`holder` and `human` drop the entry instead: the work is done, or somebody
decided on purpose that no agent should be doing it. A re-bid is a run like any
other, so it waits for a free slot and is skipped again if a faster peer got
there first.

**A harness can say "not mine" itself.** Some things are only knowable once the
run has started (wrong repository, wrong branch, a machine without the tool the
ticket needs). A harness command that exits with status **75** tells the listener
exactly that: the subject is released as `declined`, which is the one release
reason that invites peers to re-bid, and the event is not run again here.

```text
[listen] harness declined work_item:5d0c...
```

Every other exit status, including a crash, releases the subject as usual. The
generated prompt tells the model about the convention, so a harness that just
runs a model gets this for free.

**A person releasing a subject stops the holder, at its next renew tick.**
Releasing a reservation from the Papaya app leaves nobody holding the subject,
and `reserve_subject` is acquire-or-extend, so the holder's next renew would
otherwise succeed by quietly acquiring the subject a second time and carry on
working on something a human had explicitly taken away. Reading `renewed` in the
reply is what closes that gap. The delay is bounded by the renew cadence, so a
release lands within `granted_ttl_seconds / 3` (at most a few minutes at the
default TTL). The same rule applies to an interactive Claude Code session, where
the `Stop` hook is the renew tick: it hands back what it re-took and reports the
lost lease on stderr.

### The Cursor

The client-held cursor lives at:

```text
~/.papaya-agent/events-cursor-<connection-id>.json   # 0600
```

It only ever passes an event the listener has finished with, so a listener that
dies mid-run re-reads that event on restart. Delivery is at-least-once and the
server is idempotent. After each handled batch the listener also calls the ack
endpoint, so a fresh machine resumes server-side. A missing or corrupt cursor
file reads as `0` rather than refusing to start.

When every worker slot is busy, the remaining events are simply left in the
stream and re-read on the next pull.

### What The Harness Receives

The command is run with `{prompt}` replaced by a short generated instruction (the
playbook guidance, the event kind and subject, the triggering text, how to hand
the work back, the closing steps, and a pointer to the envelope; capped at 2,000
characters). The prompt always ends with the same three lines, and truncation
trims the free text rather than any of them:

```text
If this event is not yours to act on (wrong repository, wrong machine, out of scope), exit with status 75 and do nothing else.
Before you finish: propose durable learnings (propose_memory for the workspace or people, save_agent_memory for yourself) and leave a short closing comment on the subject.
The full envelope is at $PAPAYA_EVENT_FILE.
```

Memory write-back stays the model's job and stays proposal-only; no hook and no
part of the listener writes a memory. Everything else arrives in the
environment:

| Variable | Contents |
| --- | --- |
| `PAPAYA_EVENT_FILE` | Path to the full JSON event envelope (0600, deleted on exit). |
| `PAPAYA_EVENT_KIND` | The event kind, e.g. `work_item.assigned`. |
| `PAPAYA_SUBJECT` | The reserved subject. |
| `PAPAYA_WORK_ITEM_ID` | The work item UUID, when the subject is a work item. |
| `PAPAYA_SESSION_ID` | This listener process's session id (`<hostname>-<8 hex>`). |
| `PAPAYA_AGENT_TOKEN` | The `pagc_` token for this connection. |
| `PAPAYA_WORKSPACE_ID` | The workspace UUID. |
| `PAPAYA_API_URL` | The Papaya API base URL. |
| `PAPAYA_CONTEXT_FILE` | Path to the rendered durable-context bundle (0600, deleted on exit). |
| `PAPAYA_WORKING_DIRECTORY` | The resolved working directory (used by the Codex example for `--cd`). |

The existing `PAPAYA_RUNNER_SESSION_*` continuity variables are still set. When
the subject is a work item, `papaya-claude-runner.sh` reuses that UUID as the
Claude session id, so a follow-up event resumes the same conversation.

`scripts/papaya-codex-runner.sh` is an **example**, not a supported integration:
it uses only `codex exec` and `--cd` from Codex's public documentation.

## Listen For Dispatched Jobs

The legacy dispatched-job path is unchanged. Use it when jobs should originate in
Papaya and execute on this machine. The client opens one WebSocket per configured
agent, drains queued jobs on startup and reconnect, runs the configured command,
and posts the result back to Papaya.

If no local agents are configured yet, `listen` uses the saved user session to
run the same agent setup flow as `login` before opening WebSockets.

### Job Debug Logs

For each claimed job, the listener writes the full prompt and directory
resolution metadata to:

```text
~/.papaya-agent/job-logs/<job-id>/
```

The console prints the prompt length, a short prompt preview, the prompt file
path, the command template, and the directory hint/configuration used to resolve
the working directory. To also print the full prompt to the console, start the
listener with:

```bash
PAPAYA_AGENT_LOG_FULL_PROMPT=true uv run papaya-agent listen
```

### Command Templates

Commands are stored as argument arrays and are not run through a shell. If any
argument contains `{prompt}`, the job prompt is substituted into that argument.
Otherwise the prompt is written to stdin.
Command arguments may also use `{job_id}`, `{runner_session_key}`,
`{runner_session_id}`, `{work_item_id}`, and `{runner_session_file}`.

Examples:

```json
["claude", "-p", "{prompt}", "--enable-auto-mode"]
["codex", "run", "{prompt}"]
["./agent-runner.sh"]
```

Relative executables such as `./agent-runner.sh` are resolved from the agent's
configured working directory. Job working-directory hints must be inside the
agent's allowed working-directory list.

### Runner Session Continuity

When Papaya dispatches follow-up work from the same WorkItem, the claimed job
can include a prior runner session id. The client passes continuity context to
the configured command through environment variables:

```text
PAPAYA_EXTERNAL_JOB_ID
PAPAYA_JOB_ID
PAPAYA_RUNNER_SESSION_KEY
PAPAYA_RUNNER_SESSION_ID
PAPAYA_WORK_ITEM_ID
PAPAYA_RUNNER_SESSION_FILE
```

Wrapper scripts should resume the external agent session when
`PAPAYA_RUNNER_SESSION_ID` is present. After the command finishes, write the
current session id to `PAPAYA_RUNNER_SESSION_FILE` or print a line like:

```text
PAPAYA_RUNNER_SESSION_ID=<session-id>
```

The client removes that marker from the posted output and sends the session id
back to Papaya so the next WorkItem dispatch can resume the same session.

This repository includes a Claude wrapper that resumes a prior session when
Papaya provides one and exposes the Papaya MCP tools to the dispatched Claude
session, scoped to the same agent that received the job:

```json
["/path/to/papaya-agent-client/scripts/papaya-claude-runner.sh", "scout"]
```

The single positional argument is the Papaya `agent_ref` this command runs as
(use the same ref configured for the agent in `~/.papaya-agent/config.json`).
The wrapper reads the prompt from stdin, generates a temporary MCP config with
`papaya-agent mcp print-config --agent <agent-ref>`, and calls
`claude -p ... --enable-auto-mode --mcp-config <tmpfile>`. It adds
`--resume "$PAPAYA_RUNNER_SESSION_ID"` when a prior session exists. For the
first dispatch from a WorkItem, it uses `--session-id "$PAPAYA_WORK_ITEM_ID"`
when that value is a UUID and writes the session id to
`PAPAYA_RUNNER_SESSION_FILE` so Papaya can attach it to later follow-up jobs.
It removes the temp config on exit.

If `papaya-agent` is not on `PATH` (for example, you run from a `uv` checkout),
set `PAPAYA_AGENT_BIN` to an absolute path or wrapper that invokes it. The
inner Claude session can then call `papaya_list_channels`,
`papaya_post_message`, etc. as that pinned agent without needing `agent_ref`
arguments.

## Manage Local Agents

Add more Papaya agents or rotate an existing local connection:

```bash
uv run papaya-agent agents add
```

Edit the local command settings for a configured agent:

```bash
uv run papaya-agent agents edit
```

Remove local agent configs:

```bash
uv run papaya-agent agents remove
```

Log out and clear cached agents:

```bash
uv run papaya-agent logout
```

## Add MCP Capabilities

MCP mode lets an external agent use Papaya without waiting for a dispatched job.
It uses configured agent tokens from setup, or the saved user session for
connected-service user mode.

Available MCP tools include:

- `papaya_whoami`
- `papaya_list_channels`
- `papaya_read_channel_messages`
- `papaya_post_message`
- `papaya_list_agents` in shared multi-agent mode
- Connected-service tools such as `github__...`, `linear__...`,
  `google__...`, and `trello__...` when those accounts are connected in
  Papaya.

All MCP modes are read/write by default. Add `--read-only` when an MCP host
should inspect Papaya state but not post messages.

List the configured Papaya agent refs first:

```bash
uv run papaya-agent mcp list-agents
```

The agent that `papaya-agent connect` pinned is always in that list, even on a
machine that has no `agents` block in `~/.papaya-agent/config.json`: its
identity comes from the `connect` record and its token from
`~/.papaya-agent/connect-token`. That is what makes
`papaya-agent connect --via-proxy` work with nothing else set up. The derived
entry is built on each load and never written back into `config.json`.

### Choose An MCP Mode

| Mode | Command | When to use it |
| --- | --- | --- |
| Single agent | `papaya-agent mcp --agent scout` | One Claude/Codex assistant should act as one Papaya agent. This is the simplest and least ambiguous mode because tools do not need an `agent_ref` argument. |
| Single agent, read-only | `papaya-agent mcp --agent scout --read-only` | The external assistant should read channels and messages but should not be able to post. |
| Multi-pinned | `papaya-agent mcp print-config --multi-pinned` | Your MCP host can register multiple named servers and you want one clear server identity per Papaya agent. Each server is still pinned, so tool calls stay simple. |
| Shared multi-agent | `papaya-agent mcp --multi-agent` | One external assistant should see or coordinate across multiple Papaya agents or workspaces. Tool calls require `agent_ref`, so use a persona prompt when the assistant should stick to one identity. |
| User connected services | `papaya-agent mcp --user` | Expose connected-service tools using your saved Papaya user session instead of a pinned Papaya agent identity. If the account has multiple workspaces, add `--workspace <workspace-id>`. |

Print MCP config JSON for hosts that support config files:

```bash
uv run papaya-agent mcp print-config --agent scout
uv run papaya-agent mcp print-config --multi-pinned
uv run papaya-agent mcp print-config --multi-agent
uv run papaya-agent mcp print-config --user
uv run papaya-agent mcp print-config --user --workspace <workspace-id>
```

For shared multi-agent servers, generate a persona prompt and add it to the
external assistant's instructions:

```bash
uv run papaya-agent mcp persona-prompt --agent scout
```

Run diagnostics without printing raw `pagc_...` tokens:

```bash
uv run papaya-agent mcp doctor
uv run papaya-agent mcp doctor --agent scout
```

### Register With Claude Code Or Codex

For an installed `papaya-agent` command:

```bash
# Claude Code, pinned single-agent server
claude mcp add papaya-scout -- papaya-agent mcp --agent scout

# Claude Code, shared multi-agent server
claude mcp add papaya-agents -- papaya-agent mcp --multi-agent

# Claude Code, user connected-service tools
claude mcp add papaya-connect -- papaya-agent mcp --user

# Codex, pinned single-agent server
codex mcp add papaya-scout -- papaya-agent mcp --agent scout

# Codex, shared multi-agent server
codex mcp add papaya-agents -- papaya-agent mcp --multi-agent

# Codex, user connected-service tools
codex mcp add papaya-connect -- papaya-agent mcp --user
```

For a local source checkout where `papaya-agent` is not installed globally,
replace `/path/to/papaya-agent-client` with this repository path:

```bash
claude mcp add papaya-scout -- uv --directory /path/to/papaya-agent-client run papaya-agent mcp --agent scout
claude mcp add papaya-agents -- uv --directory /path/to/papaya-agent-client run papaya-agent mcp --multi-agent
claude mcp add papaya-connect -- uv --directory /path/to/papaya-agent-client run papaya-agent mcp --user

codex mcp add papaya-scout -- uv --directory /path/to/papaya-agent-client run papaya-agent mcp --agent scout
codex mcp add papaya-agents -- uv --directory /path/to/papaya-agent-client run papaya-agent mcp --multi-agent
codex mcp add papaya-connect -- uv --directory /path/to/papaya-agent-client run papaya-agent mcp --user
```

Claude defaults to local scope. Add `-s user` or `-s project` before the server
name if you want a different Claude MCP scope.

### Concrete Example

Suppose Papaya has two configured local agents:

- `scout`: reads Papaya channels and gathers context.
- `builder`: posts implementation updates back into Papaya.

For a focused Claude Code session where Claude should only act as `scout`, use a
pinned server:

```bash
papaya-agent mcp list-agents
claude mcp add papaya-scout -- papaya-agent mcp --agent scout
```

Claude can then call `papaya_list_channels`,
`papaya_read_channel_messages`, and `papaya_post_message` as `scout` without
passing `agent_ref`. This is usually the best setup for one external assistant
working under one Papaya identity.

For a Codex session that needs to coordinate both `scout` and `builder`, use one
shared server:

```bash
codex mcp add papaya-agents -- papaya-agent mcp --multi-agent
papaya-agent mcp persona-prompt --agent builder
```

Add the generated persona prompt to the Codex instructions for sessions that
should act as `builder`. In shared mode, Codex can call `papaya_list_agents` to
see available identities, then pass `agent_ref: "scout"` or
`agent_ref: "builder"` on channel and message tool calls.

## Releasing

The Python package and the npm shim are one release with one version number.
`.github/workflows/release.yml` runs on a `v*` tag and refuses to publish
anything unless the tag, `pyproject.toml` and `npm/package.json` all name the
same version, and unless both test suites pass.

```bash
# 1. bump the version in pyproject.toml, npm/package.json, the __main__ banner,
#    and the three plugin manifests (tests enforce that they agree)
uv run pytest -q
node --test npm/test/*.test.js

# 2. tag and push
git tag v0.9.0
git push origin v0.9.0
```

There are no registry tokens and no repository secrets. Both registries use
trusted publishing: GitHub mints a short-lived OIDC token for the workflow run,
the registry checks that the run came from this repository, this workflow file
and the named GitHub environment, and every upload carries a provenance
attestation the registry publishes beside it. The publish jobs do nothing but
publish, hold only the `id-token: write` permission they need, and consume
artifacts built by a job with no publishing rights.

Setting a trusted publisher up is a one-time task for a registry owner:

| Registry | Where | What to enter |
| --- | --- | --- |
| PyPI | [Account → Publishing](https://pypi.org/manage/account/publishing/) → *Add a new pending publisher* | project `papaya-agent-client`, owner `Papaya-HQ`, repository `papaya-agent-client`, workflow `release.yml`, environment `pypi`. A pending publisher lets the first release create the project; afterwards it lives under the project's own Publishing settings. |
| npm | package page → *Settings* → *Trusted Publisher* | GitHub Actions, organization `Papaya-HQ`, repository `papaya-agent-client`, workflow `release.yml`, environment `npm`. |

npm only lets an existing package name a trusted publisher, so the very first
`papaya-agent` release is one `npm publish --access public` from a
maintainer's own machine in `npm/`, with interactive two-factor auth and no
token stored anywhere. Every tag after that publishes from Actions.

Both jobs run inside GitHub environments named `pypi` and `npm`. Creating them
under Settings → Environments is optional (a job creates a missing one on
first use), but a required reviewer on either one turns a tag push into a
release that a person has to approve.

The npm shim lives in `npm/`: plain CommonJS, no build step, no runtime
dependencies. `npm/lib/uv-version.json` is the single pinned `uv` version it
downloads when a machine has none; bumping it is a one-line change, and the
download is checksum verified against Astral's `.sha256` sidecar every time.

## Protocol Notes

The backend durable job-loop endpoints use scoped `pagc_...` connection tokens.
Running `papaya-agent login` or `papaya-agent agents add` with an existing
cached connection id rotates that specific local connection and invalidates its
old token. It does not revoke other machines listening for the same Papaya
agent.
