Metadata-Version: 2.5
Name: papaya-agent-client
Version: 0.15.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 a coding harness on your machine to Papaya, so the model
works as one of your Papaya agents: it gets that agent's tools, persona, rules
and memories, and Papaya can see what it is doing. Two ways to use it, sharing
one connection. **Interactive**: you open Claude Code (or Cursor, or Codex)
yourself, and the bundled plugin gives the session the Papaya tools and keeps it
in step through hooks. **Unattended**: `papaya-agent listen` pulls the agent's
event stream, reserves the subject an event is about, and runs a harness for it.

The published Python package is `papaya-agent-client`, the npm package is
`papaya-agent`, and the command you run is always `papaya-agent`.

## Install

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

That is the whole setup. The npm package is a small shim: it finds `uv` on your
`PATH`, or downloads one pinned, checksum verified copy into its own directory,
and runs `papaya-agent-client==<the shim's own version>` through it. After a
successful `connect` it runs `uv tool install` so `papaya-agent` is on your
`PATH` for good, plus `uv tool update-shell` once per installed copy. To manage
the Python package yourself instead, `uv tool install papaya-agent-client` and
then `papaya-agent connect`. Either way `papaya-agent` has to stay on your
`PATH`, because the Claude Code plugin runs it both for its hooks and for the
`papaya` MCP server itself; `papaya-agent hooks doctor` tells you if it is.

Two environment variables steer the shim, and neither is needed normally.
`PAPAYA_UV` is a path to a `uv` binary of your choosing, which wins over `uv` on
`PATH` and over the downloaded copy. `PAPAYA_AGENT_SOURCE` replaces the package
spec the shim installs from: a local path, a git URL, or anything else `uv`
accepts. Without it, a version PyPI cannot resolve is retried once from the
matching git tag. The shim needs Node 18 or later and has no dependencies.

## Connect

`papaya-agent connect` signs you in, pins this machine to one Papaya agent, and
sets your harness up. `--harness` chooses between `claude` (the default),
`cursor` and `codex`.

Sign-in is a loopback redirect with PKCE. `connect` binds
`http://127.0.0.1:<ephemeral port>/callback`, asks Papaya where its sign-in page
is, opens your browser there, and waits up to five minutes. The link is printed
as well as opened, so a browser on another machine works too. What you see:

```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.
Durable context: available. Read it with the `get_my_agent_context` tool.
Declared to Papaya as runtime claude-code (renews its own leases, rule interception advisory: this session enforces the agent's rules itself).
Token saved to /Users/you/.papaya-agent/connect-token (0600). It is never written into harness config.
```

The browser authenticates **you**, and nothing else. The workspace and the agent
are chosen afterwards, in the terminal, from the same lists `--workspace` and
`--agent` filter; a single candidate is auto-picked with a line saying which,
and more than one gets a numbered prompt. If you deny the request, 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 harness instructions, the Claude Code plugin
installed, the `papaya-agent listen` line for that harness (see
[Listening for events](#listening-for-events)), and one line naming the agent
and the workspace and telling you the `papaya` tools are ready in any
repository.

Your token is stored only in `~/.papaya-agent/connect-token` and
`~/.papaya-agent/config.json`, both `0600` (or under `PAPAYA_AGENT_HOME`; see
[Environment](#environment)). `connect` never writes it into
`.mcp.json`, `settings.json`, `~/.cursor/mcp.json` or any other shared file, and
there is no environment variable for you to set anywhere.

### Sign-in fallbacks

`--device` waits for you to approve a short code in the Papaya app, which is
what to use over SSH or on a headless machine. It prints
`Open <link> and approve code ABCD-EFGH`, then the plain link and the code on
lines of their own so they are easy to copy, then `Waiting for approval in
Papaya...`, and still tries to open the browser unless you pass `--no-browser`.
In this mode you pick the workspace and the agent in the app, so `--workspace`
and `--agent` do not apply and `connect` says so. Polling honours the interval
the server asks for, doubles the wait (up to 60 seconds) when the server says to
slow down, and gives up when the code expires. An expired code, a denied request
or a code already used prints one line and exits 1; three network failures in a
row exit 2. `--password` signs in with email and password instead.

Two fallbacks happen on their own, each with a one-line notice: a Papaya server
with no browser sign-in route, or a machine that cannot bind a loopback port,
falls back to the device code, and a server with neither falls back to the
password.

### Every `connect` flag

| Flag | Meaning |
| --- | --- |
| `--harness {claude,cursor,codex}` | Which harness to configure. Default `claude`. |
| `--workspace <id\|slug\|name>` | Pick the workspace without the prompt. |
| `--agent <id\|handle\|slug\|name>` | Pick the agent without the prompt. |
| `--name <text>` | Name this connection. Default `<harness> on <hostname>`, e.g. `Claude Code on laptop`. |
| `--device` | Sign in with a device code instead of the browser. |
| `--password` | Sign in with email and password instead of the browser. |
| `--no-browser` | Print the sign-in link instead of opening it. Still the loopback flow. |
| `--no-install` | Do not install the Claude Code plugin, which `connect` otherwise does for you. |
| `--install` | Run the harness's own registration command. Cursor and Codex need this; Claude Code installs the plugin without it. Claude Code and Codex then get the `papaya-agent listen` line to run next. |
| `--print` | Print only the `mcpServers` JSON on stdout (status lines go to stderr) and install nothing. |
| `--via-proxy` | Point the harness at the legacy local proxy. See [MCP modes](#mcp-modes). |
| `--read-only` | With `--via-proxy`, expose no write tools. Without `--via-proxy` it is an error. |
| `--access-token-stdin` | For a host process that has already signed the person in. Reads their access token from stdin, mints this machine's connection, and stores it. No browser, no prompts, nothing installed, no harness configuration touched. See [Running under a host](#running-under-a-host). |
| `--json` | With `--access-token-stdin`: print one JSON object (the result, or an error with a code) instead of text. |
| `--working-directory <path>` | With `--access-token-stdin`: store this folder as the directory jobs run in and the only one they may use. The same checks as `listen --working-directory`; a refused folder is an error before anything is read or stored. |

## What Claude Code gets

`connect` installs the plugin that ships inside the Python package. To find it
and install it by hand:

```bash
python -c "from papaya_agent_client.connect import plugin_root; print(plugin_root())"
claude plugin marketplace add <plugin_root>
claude plugin install papaya@papaya-agent-client
```

### The tools

The plugin's `.mcp.json` registers one server, `papaya`, running
`${PAPAYA_AGENT_BIN:-papaya-agent} mcp passthrough` over stdio, and `connect`
writes the same entry for every harness: `{"type": "stdio", "command":
"papaya-agent", "args": ["mcp", "passthrough"]}`, without the `type` for Cursor
and Codex. That command proxies the hosted Papaya MCP server at
`https://api.trypapaya.ai/api/v1/mcp/` and reads your token out of
`~/.papaya-agent/connect-token` itself, so no URL and no token appear in any
harness config file.

The pass-through connects once before it serves, which does two jobs. A token
the hosted server rejects fails there, on your terminal, with `Papaya rejected
this machine's token. Run papaya-agent connect` on stderr and exit 1. And the
remote server's startup instructions, which carry your agent's per-connection
context, are copied onto the proxy so the model still sees them. Tools,
resources and prompts are forwarded as they are, so a tool Papaya adds server
side appears without a client release.

### The hooks

An interactive session has no wrapper process around it, so the guarantees
`papaya-agent listen` gives an unattended run (reserve, work, release) come from
Claude Code's own hooks instead. `hooks/hooks.json` wires seven events to six
bash scripts.

| Event | Script | What it does |
| --- | --- | --- |
| `SessionStart` | `papaya-session-start.sh` | Mints this session's reservation session id, appends `PAPAYA_AGENT_TOKEN`, `PAPAYA_WORKSPACE_ID`, `PAPAYA_SESSION_ID` (and `PAPAYA_API_URL` on a non-production server) to `$CLAUDE_ENV_FILE`, injects the agent's durable context, and tells Papaya what this runtime can do. |
| `PreCompact` | `papaya-precompact.sh` | Refreshes and re-injects the context so it survives compaction. |
| `UserPromptSubmit` | `papaya-prompt.sh` | Injects a header plus the new context when the context changed since this session last saw it. Silent otherwise, which is almost every prompt. |
| `PreToolUse` | `papaya-guard.sh` | Keeps writes inside configured working-directory roots, then enforces the checkable part of the agent's rules before a Papaya tool runs. Exit 2 blocks the call and tells the model why. |
| `PostToolUse` | `papaya-post-tool.sh` | Renews the session's reservations mid-turn, at most once every 180 seconds. |
| `Stop` | `papaya-stop.sh Stop` | Renews every subject reservation this session holds, at the lease length the server granted. |
| `SessionEnd` | `papaya-stop.sh SessionEnd` | Releases them. |

`PreToolUse` sees every tool so the machine boundary can inspect file and shell
writes. The guard itself evaluates agent rules only for `mcp__papaya__.*`
tools. Write containment is independent of the connection's interception
capability; an empty working-directory allowlist leaves writes unrestricted.
`SessionStart` and `PreCompact` get a 15 second timeout, the rest get 10.

**Why `Stop` renews and only `SessionEnd` releases.** `Stop` fires whenever the
model finishes responding, including a pause to ask you a question mid-task, and
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 over, is the release. For a
long turn `Stop` is not enough, which is what `PostToolUse` is for; it is gated
on `~/.papaya-agent/renew-<claude session id>.stamp` (`0600`), and while that
file is younger than 180 seconds the hook exits without starting Python.
`SessionEnd` deletes the stamp.

**When a lease goes away.** A 409 means another session holds the subject now,
so the hook releases nothing: it is no longer ours to release. A reply of
`renewed: false` means the lease was lost too (a person released it, or it
expired) and this call has just re-acquired it, so the hook releases it again
and a session never re-takes what somebody handed back. `Stop` and `PostToolUse`
print one line of `{"decision": "block", "reason": "..."}` on stdout naming each
subject, because Claude Code shows the model nothing from a hook that exits 0
and the session would otherwise carry on working a subject it lost. `SessionEnd`
never blocks, and a payload with `stop_hook_active` set is only logged, because
blocking a turn that a block already resumed would loop. All three touch only
reservations whose `holder.session_id` is this session's, so a listener sharing
the connection keeps its own; they find each other through Claude Code's
`session_id`, recorded at `SessionStart` in the per-connection context cache.

**Rule enforcement is deliberately narrow.** Only a rule carrying an `enforce`
object is mechanical: `{"deny_tool": "<glob>"}` blocks the tool outright, and
`{"require_substring": {"tool": "<glob>", "substring": "<str>"}}` blocks it
unless the serialised tool input contains that substring. The glob is matched
with the `mcp__papaya__` prefix stripped, so rules read `post_message`, and
rules with no `enforce` object are for the model to follow. Every hook is
fail-soft and enforcement fails **open**: no cache, no rules, an unreadable
payload or any exception allows the call and logs to `~/.papaya-agent/hook.log`.

### The skill, PAPAYA.md, and checking it worked

`skills/papaya-connect/SKILL.md` tells the model when to run `connect`, how to
verify with `whoami`, how to read the full context with `get_my_agent_context`
(resource `papaya://me/context`), and the memory rules: `save_agent_memory` for
things about the agent itself, `propose_memory` for the workspace or the people
in it. `PAPAYA.md` is the same guidance as plain instructions, for harnesses
that load a file instead of running hooks. `papaya-agent hooks doctor` prints
every hook the plugin registers and whether its script is present and
executable, where `papaya-agent` resolves on your `PATH`, which connection and
agent are current, and the last 20 lines of `~/.papaya-agent/hook.log`.

### Cursor and Codex

Both get the same `papaya` server, and neither runs hooks, so both load
`PAPAYA.md` for the persona and memory rules instead.
`connect --harness cursor --install` merges the `papaya` entry into
`~/.cursor/mcp.json`; `connect --harness codex --install` runs
`codex mcp add papaya -- papaya-agent mcp passthrough`, which is where
`codex exec` later finds the server, and ends by printing
`papaya-agent listen --harness codex`. Without hooks nothing renews or releases
a subject for those sessions: they rely on `papaya-agent listen` when a
listener drives them, and otherwise on lease expiry or an explicit
`release_subject`.

The whole Codex setup, which is what the app's Connect dialog shows:

```bash
uv tool install papaya-agent-client
papaya-agent connect --harness codex --install
papaya-agent listen --harness codex
```

### What a connection tells Papaya about itself

Every connection reports what is on the other end of it, which is how the app
can tell an interactive session from a listener, and how Papaya knows whether to
enforce the agent's rules itself. The plugin at `SessionStart` and
`connect --harness claude` both declare `runtime_kind: claude-code` with
`heartbeat: true` and `interception: "advisory"`, because the `PreToolUse` hook
already enforces the rules locally. `--harness cursor` and `--harness codex`
declare `cursor` and `codex` with `heartbeat: false` and
`interception: "enforcing"`, so Papaya enforces server side. A listener declares
its `--runtime-kind` (default `custom`) with `heartbeat: true` and
`interception: "none"`, plus `delivery`, `resume` and
`max_concurrent_subjects`. `SessionStart` reports at most once per connection
per 24 hours and `connect` once per run; both are fail-soft.

### Durable context from the CLI

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

`context` reads the connection the latest `connect` recorded, asks Papaya for
the agent's persona, rules and memories with that connection's token (never
asking for sensitive memories), and caches the result per connection at
`~/.papaya-agent/context-cache/<connection_id>.json` (`0600`, directory `0700`)
for 60 seconds, keyed by the hash Papaya returns. An unchanged hash reuses the
cached rendering, a changed hash overwrites it, and if Papaya cannot be reached
the last cached block is served. `--hook
{SessionStart,PreCompact,UserPromptSubmit}` is how the plugin's own scripts call
it: it prints the hook JSON on stdout and never fails a session.

## Listening for events

`papaya-agent listen` runs the connected-agent event loop for the connection
`connect` made. Every turn has the same shape, whatever harness you point it at:
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 doorbell saying "go pull", so the
listener reacts at once but never depends on the socket. At startup it reports
its capabilities to Papaya, fail-soft.

| Flag | Default | Meaning |
| --- | --- | --- |
| `--harness {claude-code,codex}` | `claude-code` | Which bundled runner drives events: `papaya-claude-runner.sh` or `papaya-codex-runner.sh`. Exits 1 before connecting if that harness's CLI (`claude` or `codex`) is not on `PATH`. |
| `--run '<command>'` | the harness's bundled runner | Your own harness command instead. `{prompt}` is replaced with the generated instruction; without it the prompt goes to stdin. No CLI is looked for. |
| `--poll-interval N` | `20` (or `PAPAYA_POLL_INTERVAL`) | Seconds between event pulls. |
| `--max-concurrent N` | `2` | Subjects worked on at once. |
| `--max-event-age SECONDS` | `720` (or `PAPAYA_MAX_EVENT_AGE`) | Skip conversational `act` events (`mention.you`, `dm.received`) Papaya wrote longer ago than this, as already handled. Assigned work never ages out. `0` turns the check off. See [Work Papaya already handled](#work-papaya-already-handled). |
| `--runtime-kind KIND` | the harness's name, or `custom` with a bare `--run` | Label reported to Papaya. An explicit `--harness` labels a `--run` command too. |
| `--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. |
| `--supervised` | off | Run under a host process: a JSON Lines protocol on stdout and stdin, every job approved by the host before it starts, human output on stderr. See [Running under a host](#running-under-a-host). |
| `--approval-timeout SECONDS` | `120` | With `--supervised`: how long a job waits for the host's decision before it is denied. |
| `--working-directory PATH` | the configured directory, else the current directory | Run every job in this folder and allow no other, overriding the configured directories for this run. See [Where jobs run](#where-jobs-run). Required with `--supervised` unless `connect` stored one. |
| `--hand-back-on-shutdown` | off | When the listener stops (shutdown, SIGTERM, SIGINT, a closed stdin), hand back every work item this machine keeps. Without it a machine that stops keeps its work items. See [Handing a work item back](#handing-a-work-item-back). |

Jobs have no hard wall-clock ceiling by default (`timeout_seconds: 0`); a
positive configured value remains an opt-in ceiling with the existing timeout
message. Liveness instead comes from each Claude Code hook and each line the
harness streams on stdout/stderr. After 30 quiet minutes
(`PAPAYA_STALL_AFTER=1800`) the listener logs and reports `job.stalled` but
continues renewing the lease. If no activity arrives during the separate
10-minute grace (`PAPAYA_STALL_GRACE=600`), it hands the subject back and stops
the harness with outcome `stalled`. Either variable accepts seconds; `0`
disables that step. Codex has no client hook, so Codex and custom harnesses are
judged from streamed output after their initial process-start activity.

### Where jobs run

A harness runs with full access to the files under its working directory (the
bundled Claude runner uses `bypassPermissions`), so that directory should be one
you chose. `--working-directory PATH` names it:

- `PATH` must be absolute, must exist and must be a directory. Symlinks are
  resolved, and the resolved folder is what jobs use.
- It is refused if it is `/`, your home directory itself, any directory above
  your home directory, or one of `/System`, `/usr`, `/bin`, `/sbin`, `/etc`,
  `/Library`, `/Applications`, `/private`, `/var` or `/tmp` (and the `/private`
  forms macOS resolves those to). Folders inside any of them, such as
  `~/code/billing`, are fine.
- It becomes the only allowed directory for the run, whatever `config.json`
  lists. A job that asks for a directory (a relative or absolute hint) runs
  there only when the hint resolves inside it; otherwise the job is refused and
  handed back as `declined`, exactly like a job the harness declines.

A refused `--working-directory` exits 2 with the reason and its code on stderr:
`working_directory_not_absolute`, `working_directory_not_found`,
`working_directory_not_a_directory` or `working_directory_refused`. Without the
flag, a terminal listener keeps using the configured directory, or the current
directory when none is configured.

`listen --supervised` has no such fallback: a host app launched from Finder has
`/` as its current directory. It needs `--working-directory`, or a directory
stored by `connect --access-token-stdin --working-directory`, and otherwise stops
with `working_directory_required` before it listens.

Claude Code and Codex are two bundled runners behind one flag, and the lines
`connect --install` printed are the ones to run:

```bash
papaya-agent listen                   # Claude Code, the default
papaya-agent listen --harness codex   # Codex
```

Both runner scripts ship inside the wheel, so neither line needs a path, from
an installed copy or a source checkout. `--harness` is also the runtime label
the listener reports, so a listener and an interactive session for the same
tool show up under one name in Papaya. If the harness's CLI is not on `PATH`
the listener says so (`Codex is not installed: ... is not on PATH.`) and exits
1 before it connects. `--run` still takes any command of your own, `{prompt}`
included, and is never checked against a binary:

```bash
papaya-agent listen --run './my-runner.sh {prompt}'                  # reported as custom
papaya-agent listen --harness codex --run './my-codex.sh {prompt}'   # reported as codex
```

The bundled Claude runner reads one environment variable of its own,
`PAPAYA_CLAUDE_PERMISSION_MODE`, which is the `--permission-mode` it passes to
`claude`. It defaults to `bypassPermissions`; set it to `acceptEdits`, `plan` or
`default` for a stricter mode. The bundled Codex runner runs `codex exec`, with
`--cd` set to the working directory, and leaves the `papaya` server to Codex's
own config, which `connect --harness codex --install` registered.

### The playbook

Each event kind maps to `act`, `acknowledge` or `ignore`. The mapping comes from
the agent's resolved playbook, edited in the Papaya app and merged by the server
over the defaults, which are `act` for `mention.you`, `work_item.assigned`,
`work_item.comment`, `dm.received` and `approval.waiting`, `acknowledge` for
`work_item.status_changed`, and `ignore` for `subject.reserved` and
`subject.released`. **No model call is made to decide.**

`acknowledge` advances the cursor and does nothing else; `ignore` does the same
and does not log it as interesting. An unknown kind is ignored, and an `act` on
an event the server did not mark reservable, or that carries no subject, is
downgraded to `acknowledge`, because the listener never reserves a subject the
server did not offer a lease on.

### 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: `"<kind> <subject> " + json.dumps(payload, sort_keys=True)`.

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

# The backend machine: anything except the frontend.
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`, so the cursor advances, nothing is reserved, and one line is logged
(`[listen] event=4512 not mine (--match/--exclude): ...`). 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
`papaya-agent listen --explain-match ./envelope.json --match '"kind": "bug"'`.
It prints the canonical string and then `Verdict: run` or `Verdict: not mine
(--match/--exclude)`, and exits `0` when the event would be worked here, `1`
when it would not, and `2` when the file is not a readable envelope. A malformed
pattern is rejected before anything connects.

### Subject reservations

A subject (`work_item:<uuid>`, `thread:<uuid>`, `dm:<uuid>`, `proposal:<uuid>`)
is the unit of work, and only one session works a subject at a time. Before
running, the listener reserves it; if another session already holds it, the
holder is logged, the event is skipped, and the cursor moves on, with no retry
storm and no jitter. While the harness runs, the lease is renewed every third of
the granted lease length, and network failures are retried on the next tick,
because the server-side expiry is the safety net and a flaky network should not
kill good work. On any exit (clean, crash or SIGTERM) it is released.

A renewal that comes back 409 means the lease was lost: the harness is killed
and the subject is **not** released, because this session no longer owns it. A
renewal that comes back `renewed: false` means the lease was lost too and this
call has just taken the subject again, so the harness is killed and the subject
**is** released, because the accidental re-acquire made this session the holder.
That second case is how a person releasing a subject stops the holder: reserving
is acquire-or-extend, so the next renew would otherwise quietly take the subject
back and carry on working on something a person had deliberately taken away.

**Losing a race is not the end of it.** A subject this listener wanted but lost
is remembered 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
(`[listen] event=4530 re-bid on work_item:5d0c... after expired`). `expired`,
`revoked` and `declined` invite a re-bid. `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. Every run gets a private, empty file named by
`$PAPAYA_DECLINE_FILE`; a run that writes a reason there, or a harness command
that exits with status **75**, says exactly that, so the subject is released as
`declined`, the one release reason that invites peers to re-bid, the reason is
logged, and the event is not run again here. Every other ending, including a
crash, releases the subject as usual. The generated prompt tells the model to
write the file, because a model cannot set its process's exit status: a session
that only *said* it exited 75 used to end with status 0 and have its subject
released as done. The bundled runners also exit 75 when the file is non-empty.

**An event for another identity never runs.** The envelope names the agent and
workspace it was written for. If either differs from this connection's, the
listener logs why and moves on without reserving the subject or starting a
harness (`identity_mismatch`). The bundled runners check the same thing again
for the MCP server they are about to hand the job, and decline (exit 75, reason
in the decline file) if it is not this connection's.

### Work Papaya already handled

Papaya routes an agent's work to a machine only while that machine is on call
(a listener that has pulled within the last 90 seconds). Events are written to
the agent's stream either way, and with no machine on call the agent in Papaya
handles them itself. A listener that starts later, or comes back after its
machine slept, would find that backlog waiting and answer it a second time. Three
rules keep it from doing that:

- **Old conversation is skipped; assigned work is not.** A conversational `act`
  event (`mention.you`, `dm.received`) whose `occurred_at` is more than
  `--max-event-age` seconds old (default 720, twelve minutes, the same window
  Papaya waits for a machine to pick a job up before handing it to the agent in
  Papaya) is treated like an `acknowledge`: the cursor moves past it, nothing is
  reserved, and one line is logged
  (`[listen] event=4812 subject=thread:5d0c... is 47m12s old; skipping it as
  already handled`). Answering a chat message hours late is worse than not
  answering it. Durable work is the opposite and ignores the window entirely:
  `work_item.assigned`, `work_item.comment`, `work_item.status_changed` and
  `approval.waiting` are still true however old the event is, so a listener that
  sat at capacity, or a machine that was asleep, for longer than the window picks
  them up rather than losing them. The age is `server_time` on the events page
  minus the event's `occurred_at`, both Papaya's clock, so a machine whose own
  clock is off still judges it correctly. Only when a page carries no
  `server_time` is this machine's clock used instead. An event with no
  `occurred_at` is never skipped for age. A `subject.released` that would invite
  a re-bid is judged by the kind of the work it releases: a late release of a
  stale mention does not bid again, a late release of assigned work does.
- **A first start does not replay history.** With no stored cursor for the
  connection (a new connection, or a missing or unreadable cursor file), the
  listener's first pull pages through the stream to its head without acting on
  anything, stores and acks that cursor, and works only events that arrive after
  it (`[listen] No stored cursor: starting at the head of the stream (cursor
  4830); 212 earlier event(s) not replayed`). Papaya has no "latest cursor" read,
  so this costs one pass over the stream, 200 events a page, once.
- **A subject Papaya took over is not a lost race.** When the agent in Papaya
  took a job over from the agent's machines, Papaya refuses that subject to them
  for 24 hours, naming the holder `papaya-hosted`. The listener logs
  `already handled in Papaya`, moves on, and does not remember the subject for a
  re-bid.
- **Work sent to someone else is not a lost race either.** When several people
  run the same agent, Papaya gives each piece of work to one person's machines:
  the person who triggered it, else (for a work item) its assigner, requester or
  previous owner, else the agent in Papaya. A work item another machine keeps
  stays with that machine. A running machine that asks for anything else is
  refused with a `409` whose holder `session_id` is `not-routed-to-this-machine`,
  `routed` or `held`. The listener logs `was not sent to this machine (<machine>
  has it)`, moves on, and does not remember the subject for a re-bid. A
  supervised host gets `job.skipped` with reason `not_routed_here`, and no error.

A page with more events behind it is followed at once rather than after the poll
interval, so a listener working through a backlog reaches fresh events before
they age out.

### Handing a work item back

Once a running machine takes a work item, Papaya keeps it with that machine's
connection: later comments on it, and reassignments back to the agent, come to
the same machine first, even while it is offline (the agent in Papaya covers
once and says the item stays with the machine). A machine gives a work item back
when it should not keep it:

```bash
papaya-agent hand-back --subject work_item:5d0c1e2a-3b4c-4d5e-8f60-718293a4b5c6
papaya-agent hand-back            # every work item this connection keeps
papaya-agent hand-back --json
```

It uses the connection in `PAPAYA_AGENT_HOME` (default `~/.papaya-agent`) and
calls `POST /api/v1/workspaces/{workspace}/subjects/hand-back`. With a subject,
that work item's hold ends and a run this connection has on it is released as
`declined`, so waiting work goes to the agent in Papaya at once; without one,
every hold ends and runs are left alone. It is always safe to run: handing back
something this machine does not keep is not an error. `--json` prints
`{"ok":true,"subject":...,"handed_back":true,"holds_ended":1,"released_run":false}`,
or `{"ok":false,"error":{"code":...,"message":...}}` with `invalid_subject`
(exit 2), `invalid_endpoint` (2), `not_connected` (1), `token_rejected` (1) or
`api_error` (2).

The command does not stop a run another `listen` process is working; that
listener stops it when its next lease renewal is refused. A host running
`listen --supervised` sends the protocol's `hand_back` message instead, which
stops the run at once (see [docs/supervised-protocol.md](docs/supervised-protocol.md#hand_back)).

A job can hand its own work item back too: for a work item, the generated
prompt tells the model to call the `hand_back_subject` tool (the job's Papaya
MCP server has it) when the item cannot be finished on this machine, and then to
decline.

**Stopping does not hand anything back.** A listener that stops (Ctrl+C,
SIGTERM, a host's `shutdown`) keeps its work items, because a laptop that closes
for the night should still be the machine its work items come back to. Pass
`listen --hand-back-on-shutdown` for a machine that should give them all back
whenever it stops.

### The cursor

The client-held cursor lives at
`~/.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, provided it is still inside the staleness
window; delivery is at-least-once and the server is idempotent. After each
handled batch the listener also acks the cursor server side. A missing or
corrupt cursor file never stops the listener: it starts at the head of the
stream, as above. When every worker slot is busy the remaining events are left
in the stream for the next pull.

### What the harness receives

The command runs 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. It is capped at
2,000 characters, and truncation trims the free text rather than these three
closing lines (four for a work item, whose prompt first says how to
[hand it back](#handing-a-work-item-back): call `hand_back_subject` with its
subject, then decline):

```text
If this event is not yours to act on (another agent or workspace, wrong repository, wrong machine, out of scope), write a one-line reason to the file named by $PAPAYA_DECLINE_FILE and stop without doing anything 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.
```

Writing memories 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>-l-<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_JOB_ACTIVITY_FILE` | Private liveness stamp touched by listener-job Claude hooks and every streamed output line (`0600`, deleted on exit). |
| `PAPAYA_WORKING_DIRECTORY` | The resolved working directory, which the Codex example passes to `--cd`. |
| `PAPAYA_DECLINE_FILE` | An empty private file (`0600`, deleted on exit). A reason written here declines the job. |
| `PAPAYA_AGENT_ID`, `PAPAYA_AGENT_REF` | This connection's agent, which the runners check the job's MCP identity against. |
| `PAPAYA_PLUGIN_DIR` | The Claude Code plugin bundled with this client, which the Claude runner loads. |
| `PAPAYA_JOB_ID` | The job id (`event-<id>`). The plugin's hooks switch to listener-job behaviour on it. |

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. To
invoke a harness differently, point `--run` at your own copy of its runner.

### What an unattended job loads

A job acts as this connection and nothing else, whatever else the machine has
configured. Both CLIs normally also load the person's own MCP servers, and a
`papaya` server left there by an earlier terminal `connect` is another agent,
possibly in another workspace or environment. So the bundled runners ask
`papaya-agent mcp runner-config` for each job's MCP configuration:

| | Claude Code (`papaya-claude-runner.sh`) | Codex (`papaya-codex-runner.sh`) |
| --- | --- | --- |
| Papaya MCP tools | One `papaya` server: `papaya-agent mcp passthrough --agent <ref>`, pinned to this home (`PAPAYA_AGENT_HOME`) and endpoint, so `mcp__papaya__*` is this connection's full hosted tool set. | One `papaya-job` server: this connection's Papaya MCP URL with `bearer_token_env_var = "PAPAYA_AGENT_TOKEN"` (the pinned pass-through when no token is exported). |
| The person's other MCP servers | Not loaded: `--strict-mcp-config` skips user, project and local servers, plugin servers and claude.ai connectors. `PAPAYA_RUNNER_INHERIT_MCP=1` copies the non-Papaya ones into the job's config. | Switched off: every `[mcp_servers.*]` in `~/.codex/config.toml` and the project's `.codex/config.toml` gets `-c mcp_servers.<name>.enabled=false`. `PAPAYA_RUNNER_INHERIT_MCP=1` switches off only the Papaya ones. |
| An existing Papaya entry for another identity | Never loaded. | Always switched off, inherit or not. One whose name `codex -c` cannot address (dots, quotes) stops the job with an error instead. |
| Skill and hooks | The plugin bundled with this client version, via `--plugin-dir` (which also shadows a `papaya` plugin installed globally for another identity). In a job the hooks import context and enforce rules for this home, but do not mint a session, declare the runtime, renew or release: the listener owns those. `PAPAYA_RUNNER_NO_PLUGIN=1` skips it. | None: Codex has no Papaya hooks. The persona and rules arrive through the MCP server's instructions and `get_my_agent_context`. |
| Durable context | SessionStart hook block, `$PAPAYA_CONTEXT_FILE`, and `get_my_agent_context`. | `$PAPAYA_CONTEXT_FILE` and `get_my_agent_context`. |
| Identity check | `runner-config` exits 75 (reason in `$PAPAYA_DECLINE_FILE`) when the `--agent` ref resolves to a different agent or workspace than `PAPAYA_AGENT_ID`/`PAPAYA_WORKSPACE_ID`. | The same, and also when `$PAPAYA_AGENT_TOKEN` is not that agent's token. |

A custom `--run` command gets the environment and the decline file, but no MCP
isolation unless it calls `papaya-agent mcp runner-config` itself.

### Legacy: dispatched jobs

There is an older model in which Papaya dispatches a job to this machine and the
client runs a configured local command for it. It is still supported, and
`listen` uses it when this machine has no connection from `papaya-agent
connect`, meaning a machine set up with `papaya-agent login` instead. The client
opens one WebSocket per configured agent, drains queued jobs on startup and
reconnect, runs the agent's configured command, and posts the result back.

The command is an argument array and is never run through a shell: an argument
containing `{prompt}` receives the job prompt, otherwise the prompt goes to
stdin, and `{job_id}`, `{runner_session_key}`, `{runner_session_id}`,
`{work_item_id}` and `{runner_session_file}` are substituted the same way. Job
working-directory hints must resolve inside the agent's allowed directories: a
hint outside them runs in the configured directory when that one is allowed, and
otherwise the job is completed as failed without running. With
`--working-directory`, a hint outside that folder is refused outright.
Each claimed job leaves its prompt and directory resolution metadata in
`~/.papaya-agent/job-logs/<job-id>/`, and `PAPAYA_AGENT_LOG_FULL_PROMPT=true`
also prints the whole prompt to the console.

Follow-up work from the same work item can carry a prior session id, which
reaches the command as `PAPAYA_RUNNER_SESSION_KEY`, `PAPAYA_RUNNER_SESSION_ID`
and `PAPAYA_RUNNER_SESSION_FILE`, alongside `PAPAYA_JOB_ID`,
`PAPAYA_EXTERNAL_JOB_ID` and `PAPAYA_WORK_ITEM_ID`. A wrapper should resume the
external session when `PAPAYA_RUNNER_SESSION_ID` is set and write the current
session id to `PAPAYA_RUNNER_SESSION_FILE` when it finishes, so the next
dispatch resumes it. `scripts/papaya-claude-runner.sh` does that: configure it
as the agent's command with the Papaya `agent_ref` as its one argument
(`["/path/to/scripts/papaya-claude-runner.sh", "scout"]`), and with no prompt
argument it reads the prompt from stdin, which is what this path does.

## Running under a host

Papaya Desktop keeps a Mac on call without a terminal by running this client as
a child process. Three pieces make that work, and a terminal user needs none of
them:

```bash
# A home of its own, so it never shares a token, cursor or log with a terminal client.
export PAPAYA_AGENT_HOME="$HOME/Library/Application Support/Papaya/agent-client"

# Connect once without a browser. The access token is written to stdin.
papaya-agent connect --workspace papaya --agent scout \
  --access-token-stdin --no-install --json \
  --working-directory "$HOME/code/billing"

# Listen in that folder, approving each job.
papaya-agent listen --supervised --working-directory "$HOME/code/billing"
```

`connect --access-token-stdin` takes the access token of a person the host has
already signed in, uses it for the same workspace, agent and `connections/ensure`
calls the browser flow ends with, and stores this machine's own `pagc_` token
under `PAPAYA_AGENT_HOME`. It never stores or refreshes the access token, and
prints neither token. With `--json` it prints one object, `{"ok": true, ...}` or
`{"ok": false, "error": {"code": ..., "message": ...}}`, and exits 0, 1 (refused)
or 2 (bad arguments or a server failure). More than one matching workspace or
agent is an error rather than a prompt. `--working-directory` stores the folder
jobs run in, and the result reports it as `working_directory`.

`listen --supervised` writes only JSON Lines to stdout (`hello`, `status`,
`job.request`, `job.skipped`, `job.started`, `job.stalled`,
`job.stall_cleared`, `job.finished`, `hand_back.result`, `error`) and reads
`job.decision`, `hand_back` and `shutdown` from stdin. A job runs only after the host answers
its `job.request` with `"allow": true`. A denial, no answer within
`--approval-timeout`, a closed stdin, a malformed message, a lost lease or a
shutdown all mean the job does not run, and the subject is released as
`declined`, the reason that invites another listener for the agent to take it.
Supervised mode refuses to listen without a working directory (see
[Where jobs run](#where-jobs-run)); `hello` and every `job.request` carry the
resolved `working_directory`, so the host can show where a job will run.
Every message, the approval rules and what happens to the cursor are in
[docs/supervised-protocol.md](docs/supervised-protocol.md).

`{"type":"hand_back","subject":"work_item:<uuid>"}` gives one work item back and,
when Papaya released this machine's run on it, stops that run (`job.finished`
with `outcome` `handed_back`); leave `subject` out to give back every work item.
Each is answered with `hand_back.result`. `shutdown` alone keeps the machine's
work items, so Papaya Desktop's "Stop running on this Mac" sends `hand_back`
with no subject first, then `shutdown`.

A host can also show the Papaya MCP entries the person's harnesses already
carry, and who each one acts as, since an entry left by an earlier terminal
`connect` makes an interactive session talk to Papaya as another agent:

```bash
papaya-agent harness-conflicts --harness claude --working-directory "$HOME/code/billing" --json
papaya-agent harness-conflicts --remove "claude:user:papaya" --json
```

The listing covers Claude Code's user, local and project scopes, Codex's user
and project config, and Cursor's user and project `mcp.json`, and resolves each
token's agent and workspace with a read-only `whoami` (never printing a token).
`--remove` prefers `claude mcp remove` or `codex mcp remove`, otherwise edits
the file after a timestamped backup, and refuses the entry this home's
connection owns. The JSON shape and error codes are in
[docs/supervised-protocol.md](docs/supervised-protocol.md#papaya-entries-a-harness-already-has).

## Embedding the listener

A Python host can run the event loop in its own process instead of starting
`papaya-agent listen` as a child, and execute each approved job however it
likes. `papaya_agent_client.embed` is the whole surface:

```python
from papaya_agent_client.embed import build_listener

async def my_runner(job):
    # `job` is a `listener.Job`: the event envelope, the playbook decision, the
    # generated prompt, the job id, the working directory, the environment a
    # harness would have been given, the output log path and the job's stop
    # signal. Answer the dict `run_command` answers.
    job.touch_activity()          # still alive, so the stall check stays quiet
    ...
    return {"request_id": job.job_id, "exit_code": 0, "output": "", "error": None}

listener = await build_listener(
    runner=my_runner,
    home="/path/to/agent/home",            # the `PAPAYA_AGENT_HOME` rule
    working_directory="/path/to/project",  # then the only allowed root
    session_id=stored_session_id,          # optional; see below
    runtime_kind="my-runtime",             # what the connection announces
)
await listener.loop.run()
```

Everything the terminal listener does is intact, because it is the same loop:
the cursor, the reservation and its renewal, lost-lease detection, staleness
classes, stall observation and the grace hand-back, hand-back on demand, and the
supervised protocol. A runner replaces only the subprocess.

Four things are worth knowing. A runner answers exit code 75, or writes a line
to the file named by `PAPAYA_DECLINE_FILE` (`job.decline("...")`), to say the
work is not this machine's, exactly as a harness does; the subject then goes
back as `declined`. A runner that raises fails its job, releases the subject and
leaves the loop running. The job's four temporary files live only for the length
of the call. And `session_id` is the lease identity: a host that stores one
across restarts takes its own leases back instead of waiting for them to expire,
while a host that does not gets a fresh id per process, like the CLI.

`build_supervised_listener(writer, stdin_fd=...)` is the same wiring with a
`Supervisor` bound to the host's stdio, so a host that wants to approve each job
before it starts gets the protocol below without reimplementing it. Both entry
points raise `ListenerSetupError` (with `code`, `message` and `status`) for a
home with no connection, a refused working directory or a missing harness CLI.
`papaya-agent listen` and `listen --supervised` are argument parsing, a banner
and a websocket around these two calls, so there is one wiring, not two.

## Managing agents and sessions

| Command | What it does |
| --- | --- |
| `papaya-agent login` | Sign in with email and password, then configure local agent connections for the legacy job model. It **replaces** the whole configured set, and does not open WebSockets or start listening. |
| `papaya-agent login --token-stdin [--workspace <slug>]` | Store a connection token the Papaya app generated for a headless machine, read from stdin (a hidden prompt in a terminal). It asks Papaya which agent, workspace and connection the token is for, refuses it if `--workspace` names a different workspace, and leaves the machine ready for `papaya-agent listen`. `--token <token>` is accepted too, with a warning, because an argument is visible to other processes and stays in shell history. `--json` prints one JSON object. |
| `papaya-agent agents add` | The same configuration flow, merging into what is already there. Re-running it for an agent that already has a connection rotates that connection's token. |
| `papaya-agent agents edit` | Edit one configured agent's command, working directory, allowed directories and timeout. Choose exactly one. |
| `papaya-agent agents remove` | Remove configured agents. |
| `papaya-agent logout` | Clear the saved user session and every configured agent. It does not delete `~/.papaya-agent/connect-token`; run `connect` again to replace that. |
| `papaya-agent workspaces` | List workspace IDs, slugs and names for the saved user session. |
| `papaya-agent context` | Print the connected agent's durable context. See [above](#durable-context-from-the-cli). |
| `papaya-agent hand-back [--subject work_item:<id>] [--json]` | Stop keeping a work item (or every one) on this machine. See [Handing a work item back](#handing-a-work-item-back). |

For each agent, `login` and `agents add` ask for a display name, the command to
run when Papaya dispatches a job (default `claude -p {prompt}
--enable-auto-mode`), a working directory, the allowed working directories, and
a timeout in seconds (`0`, meaning no hard ceiling, by default). Your password is never stored. The saved
config lives at `~/.papaya-agent/config.json` (`0600`) and holds the user
refresh token plus one scoped `pagc_...` token per configured agent; rotating a
connection invalidates that connection's old token and no other machine's.

`workspaces` prints a column-aligned table of IDs, slugs and names by default,
one JSON object per line with `--json`, and IDs only with `-q`. Without a saved
user session it prints `Run papaya-agent login first.` and exits 1.

## Environment

None of these are needed normally. Each is read on every command, so switching
is just the variable.

| Variable | Default | Meaning |
| --- | --- | --- |
| `PAPAYA_ENV` | production | `production` (or `prod`), `staging` or `local`. Staging is `https://api-staging.trypapaya.ai`, local is `http://localhost:5173`. Any other value is an error, so a typo never quietly means production. |
| `PAPAYA_API_URL` | from `PAPAYA_ENV` | The API origin, for example `https://api-staging.trypapaya.ai`. Wins over `PAPAYA_ENV`. Must be `https://` unless the host is `localhost`, `127.0.0.1` or `::1`, and must carry no credentials, query or fragment. The WebSocket URL is derived from it (`https` to `wss`). |
| `PAPAYA_WS_URL` | derived | Only when the WebSocket lives somewhere other than the API origin. `wss://`, or `ws://` for a loopback host. |
| `LOCAL_PAPAYA` | unset | `true` is the older spelling of `PAPAYA_ENV=local`, and loses to both variables above. |
| `PAPAYA_MAX_EVENT_AGE` | `720` | Seconds after which `listen` skips a conversational `act` event as already handled; assigned work never ages out. `--max-event-age` wins. `0` turns the check off. |
| `PAPAYA_STALL_AFTER` | `1800` | Quiet seconds before a running job is observed as stalled. `0` disables stall detection. |
| `PAPAYA_STALL_GRACE` | `600` | Further quiet seconds after the stall observation before hand-back and harness stop. `0` makes hand-back immediate. |
| `PAPAYA_AGENT_HOME` | `~/.papaya-agent` | Where every file the client reads or writes lives: `config.json`, `connect-token`, the event cursors, the context cache, `hook.log`, renew stamps and job logs. Use an absolute path. |

There is no variable for the web app's address. `connect` asks the API where its
sign-in page is, and staging's API answers with
`https://app-staging.trypapaya.ai/cli/authorize`. A bad value exits 2 with one
line naming the variable.

## MCP modes

`papaya-agent mcp passthrough` is the default: the full hosted tool set, proxied
over stdio, with the token read from `~/.papaya-agent/connect-token`. It is what
the plugin runs and what `connect` writes into every harness config, and
`--agent <ref>` serves a configured agent's stored token instead, so one machine
can serve a second agent without re-running `connect`. The only reasons to
choose `papaya-agent connect --via-proxy` instead are `acting_as` on every write
result, so the harness can see which Papaya agent it just spoke as, and
`--read-only`, which exposes no write tools at all.

### The legacy local proxy

`papaya-agent mcp` is a hand-built proxy that predates the pass-through, kept
for those two things alone, and its tool set is a hand-maintained subset rather
than whatever the hosted server publishes. `connect --via-proxy` emits it as
`papaya-agent mcp --agent <ref> [--read-only]`, and with `--install` registers it
through `claude mcp add` or `codex mcp add` (or, for Cursor, by merging into
`~/.cursor/mcp.json`). Run directly, exactly one of these is required:

- `--agent <ref>`: one assistant acts as one Papaya agent. Tool calls need no
  `agent_ref`, which makes it the least ambiguous mode. Add `--read-only` for a
  session that must read Papaya but not write to it.
- `--multi-agent`: one assistant coordinates across several Papaya agents. Tool
  calls require `agent_ref`.
- `--user`: expose connected-service tools using your saved Papaya user session
  rather than an agent identity. Add `--workspace <id>` if the account has more
  than one workspace.

The tools it exposes are `papaya_whoami`, `papaya_list_channels`,
`papaya_read_channel_messages` and, unless `--read-only`, `papaya_post_message`;
`--multi-agent` adds `papaya_list_agents`. Connected-service tools (for example
`github__...`, `linear__...`, `google__...`, `trello__...`) are loaded in
`--agent` and `--user` mode only, never in `--multi-agent` mode, and only for
providers the workspace has connected. Under `--read-only` those are filtered by
name, which is a heuristic: a tool whose name reads as a write is dropped.

```bash
papaya-agent mcp list-agents                        # agent_ref, name, workspace id
papaya-agent mcp print-config --agent scout         # one pinned server
papaya-agent mcp print-config --multi-pinned        # one server per configured agent
papaya-agent mcp print-config --multi-agent         # one shared server
papaya-agent mcp print-config --user --workspace <workspace-id>
papaya-agent mcp persona-prompt --agent scout       # instructions pinning a shared server to one identity
papaya-agent mcp doctor --agent scout               # diagnostics, no raw tokens
```

`print-config` also takes `--read-only`, and `persona-prompt` requires
`--agent`. The agent that `papaya-agent connect` pinned is always in
`list-agents`, even on a machine with no `agents` block in
`~/.papaya-agent/config.json`: its identity comes from the `connect` record and
its token from `~/.papaya-agent/connect-token`. That derived entry is rebuilt on
every load and never written back into `config.json`, which is what makes
`papaya-agent connect --via-proxy` work with nothing else set up.

## Troubleshooting

**No `papaya` tools after connecting.** Run `papaya-agent connect` again and
restart the harness. There is no environment variable to set and nothing to put
in a shell profile; the two things worth checking are that `papaya-agent` is on
your `PATH` and that the plugin is installed, and `papaya-agent hooks doctor`
answers both.

**A Papaya tool returns 401, or the server says the token was rejected.** The
connection's token is stale: run `papaya-agent connect` again and restart the
session. The pass-through says the same on its stderr and exits 1 rather than
serving a broken session.

**`papaya-agent: command not found`, or the harness cannot find it.** The
harness starts the CLI by name, so it has to be on the `PATH` the harness
inherited. Install it with `uv tool install papaya-agent-client` and open a new
terminal, or set `PAPAYA_AGENT_BIN` to an absolute path; `hooks doctor` prints
which of those is in effect.

**A machine with no browser (SSH, a container, a server).** Use `papaya-agent
connect --device` and approve the short code in the Papaya app, or `papaya-agent
connect --no-browser` and open the printed link from another machine.

**A Papaya server without the browser sign-in route.** `connect` falls back on
its own, printing `This Papaya server has no browser sign-in yet; using the
device code instead.` and continuing with the device flow; a server with no
device flow either falls back again to email and password.

**`listen` says the harness is not installed.** `--harness` resolves a bundled
runner that starts `claude` or `codex` by name, so that CLI has to be on the
`PATH` of the terminal running `listen`. Install it and run `listen` again, or
pass `--run '<command with {prompt}>'` to drive something else.

**The listener skipped an event as "already handled".** It was a conversational
event older than `--max-event-age` (twelve minutes by default), or Papaya had
already handed the subject to the agent in Papaya. Either way the agent in Papaya answered it, which
is what happens to any work that arrives while no machine is on call. A listener
that has been off for a while, or is starting for the first time, does not catch
up on that work on purpose.

**The listener says an event is "not mine".** That is `--match` or `--exclude`
refusing it. Check the pattern with `papaya-agent listen --explain-match
<envelope.json>`, which prints the string your pattern is tested against.

## Developing this client

### Repository layout

| Path | What is in it |
| --- | --- |
| `papaya_agent_client/__main__.py` | The argparse tree, the authority for every command and flag, plus `login`, `logout`, `workspaces` and the legacy job loop. |
| `papaya_agent_client/connect.py` | `connect`: sign-in flow selection, workspace and agent choice, harness config, plugin install. |
| `papaya_agent_client/browser_signin.py` | The loopback listener and the PKCE sign-in. |
| `papaya_agent_client/auth.py` | Password sign-in and session refresh. |
| `papaya_agent_client/api_client.py` | Every Papaya HTTP call, and the typed errors callers branch on. |
| `papaya_agent_client/config.py` | `PAPAYA_AGENT_HOME`, `config.json`, endpoint selection and its overrides, agent configs. |
| `papaya_agent_client/context.py` | Durable context: fetch, hash cache, compose, and the `SessionStart`, `PreCompact` and `UserPromptSubmit` hooks. |
| `papaya_agent_client/hooks.py` | The `Stop`, `PostToolUse` and `SessionEnd` hooks, the renew stamp, the hook log, and `hooks doctor`. |
| `papaya_agent_client/guard.py` | The `PreToolUse` hook: write-boundary orchestration and agent-rule evaluation. |
| `papaya_agent_client/write_boundary.py` | Resolved-root write containment for Claude file and shell tools, plus denial receipts. |
| `papaya_agent_client/listener.py` | The event loop: pull, playbook, scope filter, reserve, renew, release, re-bid, cursor. |
| `papaya_agent_client/supervisor.py` | `listen --supervised`: the JSON Lines protocol, the approval gate and the status phases. The contract is `docs/supervised-protocol.md`. |
| `papaya_agent_client/headless.py` | `connect --access-token-stdin` and `login --token-stdin`: connecting with no browser. |
| `papaya_agent_client/working_directory.py` | Where jobs run: validating `--working-directory` and keeping each job's directory inside it. |
| `papaya_agent_client/playbook.py` | The `act` / `acknowledge` / `ignore` decision and the default table. |
| `papaya_agent_client/command_runner.py` | Running a harness command: token substitution, streaming, timeout, cancellation. |
| `papaya_agent_client/passthrough.py` | `mcp passthrough`: the proxy of the hosted MCP server. |
| `papaya_agent_client/mcp_server.py` | The legacy hand-built proxy and the `print-config` / `doctor` / `persona-prompt` helpers. |
| `papaya_agent_client/harness_mcp.py` | What an unattended job's harness loads: `mcp runner-config`, Papaya-entry detection, and the Claude Code and Codex job configs. |
| `papaya_agent_client/hand_back.py` | `hand-back`: giving a work item this machine keeps back to Papaya. |
| `papaya_agent_client/harness_conflicts.py` | `harness-conflicts`: Papaya MCP entries in the person's Claude Code, Codex and Cursor configs, their identities, and safe removal. |
| `papaya_agent_client/mcp_identity.py` | Stable `agent_ref` slugs and the derived connect identity. |
| `papaya_agent_client/ws_client.py` | The WebSocket carrying the doorbell and the legacy job notification. |
| `papaya_agent_client/plugin/` | The plugin, shipped as package data: `.mcp.json`, `hooks/`, `skills/`, `PAPAYA.md`, and the Claude, Cursor and Codex manifests. |
| `scripts/` | The bundled runners `papaya-claude-runner.sh` and `papaya-codex-runner.sh`, one per `listen --harness`. Both are force-included in the wheel. |
| `npm/` | The `papaya-agent` shim: `bin/`, `lib/` (uv bootstrap, checksum, targets, source spec), `test/`. |
| `docs/supervised-protocol.md` | The supervised-mode protocol: every message, the version, and the approval semantics. |
| `tests/` | `test_connect.py` (connect, the plugin, the hooks, the doc rules), `test_listener.py`, `test_supervised.py`, `test_headless.py`, `test_working_directory.py`, `test_endpoints_and_home.py`, `test_passthrough.py`, `test_runner_isolation.py` (job MCP isolation, decline file, identity guards), `test_harness_conflicts.py`, `test_hand_back.py` (hand back, `not_routed_here`), `test_client_runtime.py` (the legacy job path), `test_version_pins.py`. |

### Local setup

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

Every command in this README works from a source checkout with `uv run` in front
of it. To point the client at a Papaya running on your machine, set
`PAPAYA_ENV=local` (or the older `LOCAL_PAPAYA=true`), for example
`PAPAYA_ENV=local uv run papaya-agent connect`; `PAPAYA_ENV=staging` targets
staging. With none of the [environment](#environment) variables set the client
talks to production, `https://api.trypapaya.ai` over HTTP and
`wss://api.trypapaya.ai` for the socket. Endpoints are re-derived from the
environment on every load and rewritten into `config.json` on every save, so
switching is just the variable. Add `PAPAYA_AGENT_HOME=/tmp/some-dir` to try
something without touching your real `~/.papaya-agent`.

### Running the tests

```bash
uv run pytest -q                          # the Python suite
node --test test/*.test.js                # the npm shim, run from npm/
claude plugin validate papaya_agent_client/plugin   # the plugin manifests
```

CI runs the first two on every pull request, against Python 3.11 and 3.13 and
Node 18 and 22. Some tests keep this repository honest rather than testing
behaviour, and they are the ones most likely to fail on a documentation change.
In `tests/test_connect.py`: `test_plugin_and_docs_avoid_em_dashes` forbids an em
dash anywhere in the plugin, this README or `docs/supervised-protocol.md`;
`test_docs_never_tell_anyone_to_export_a_token` forbids any plugin file or part
of this README telling somebody to put a token in a shell profile; and
`test_version_is_pinned_across_pyproject_banner_and_manifests` requires
`pyproject.toml`, the CLI banner and the three plugin manifests to carry one
version. `tests/test_version_pins.py` requires `npm/package.json` to match
`pyproject.toml`, forbids a version literal in the shim's source, checks `uv` is
pinned once and its download checksum verified, and checks the release workflow
gates on the version and publishes with no stored token.

### Working on the plugin

`claude --plugin-dir papaya_agent_client/plugin` loads the plugin for one
session without installing it. The hooks shell out to `papaya-agent` by name, so
in a source checkout point `PAPAYA_AGENT_BIN` at a wrapper that runs `uv run
papaya-agent`, or install the package with `uv tool install .`. `papaya-agent
hooks doctor` shows which scripts are registered and whether each is present and
executable, and `~/.papaya-agent/hook.log` is where a fail-soft hook records
what went wrong.

### Bumping the version

The version appears in seven places and tests enforce that they agree:
`pyproject.toml` (`project.version`), `npm/package.json` (`version`), `_BANNER`
in `papaya_agent_client/__main__.py`, `plugin.json` in each of
`papaya_agent_client/plugin/.claude-plugin/`, `.cursor-plugin/` and
`.codex-plugin/`, and the version literal asserted in `tests/test_connect.py`.
The git tag is the eighth, and the release workflow checks it against the first
two.

### 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 every version listed above
uv run pytest -q
node --test npm/test/*.test.js

# 2. tag and push
git tag v0.15.0
git push origin v0.15.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. On
[PyPI](https://pypi.org/manage/account/publishing/), under *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. On npm, from the package page under
*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 is plain CommonJS with no build step and 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.

## History

Two planning documents from before this client shipped are kept under
`docs/history/`, and describe intentions rather than the current client: the
[MCP plan](docs/history/papaya-agent-client-mcp-detailed-implementation-plan.md)
and the
[`workspaces` plan](docs/history/papaya-agent-client-workspaces-command-plan.md).
