Skip to main content
Glama
README.md
# RelayKeep

**English** | [日本語](README.ja.md)

**Hand off tasks. Keep the results.**

RelayKeep lets an MCP client hand a task to a local queue and read the result back later, by the same ID, as many times as it likes, even from a new session. A worker that you start yourself picks up queued tasks and runs them through a local model or a coding-agent CLI. A separate, optional MCP server gives read-only lookup over a small memory file.

It is a small, early-stage (`0.0.0.dev5`) project for one person on one machine. It is not published on PyPI or any other package index.

## Contents

- [Try the demo](#try-the-demo)
- [Limits in short](#limits-in-short)
- [How it works](#how-it-works)
- [What it does and doesn't do](#what-it-does-and-doesnt-do)
- [Requirements](#requirements)
- [Connecting an MCP client](#connecting-an-mcp-client)
- [Using the queue without MCP](#using-the-queue-without-mcp)
- [Delegating to Claude Code, Codex or Ollama](#delegating-to-claude-code-codex-or-ollama)
- [Optional memory server](#optional-memory-server)
- [Reliability notes](#reliability-notes)
- [Security](#security)
- [Contributing](#contributing)
- [Status and license](#status-and-license)

## Try the demo

You need Python 3.13 or newer on Linux or macOS.

```bash
git clone https://github.com/tsunamayo7/relaykeep.git
cd relaykeep
python3.13 -m venv .venv
. .venv/bin/activate
python -m pip install .
python -m relaykeep.demo
```

`pip install` downloads the `mcp` SDK and its dependencies. The demo itself calls no model and needs no login or API key. It creates a temporary directory, starts the real `relaykeep-tasks` stdio MCP server, and checks each step:

1. An MCP client submits the same request twice and gets the same receipt. The task stays `queued`, because the MCP server never runs tasks.
2. The client disconnects. One worker pass is run explicitly and completes the task with the built-in echo provider.
3. A new MCP session reads the result twice with the same receipt and gets the same answer. Submitting the same ID with a different payload is rejected with `ConflictError`.
4. A second worker pass dispatches nothing.

The temporary directory is removed at the end. The demo exits 0 when every step passes and 1 otherwise.

## Limits in short

- A receipt means "queued", not "done". Nothing runs until you start `relaykeep-worker`.
- No exactly-once execution, no timeouts, no retries, no automatic model fallback.
- `cancel` works on `queued` tasks only.
- One trusted user on one local POSIX machine. stdio only, no authentication.
- It keeps task results. It does not restore a conversation or resume a half-finished run.

Details are in [Reliability notes](#reliability-notes) and [Security](#security).

## How it works

```mermaid
flowchart LR
    client["MCP client"] -->|"stdio: submit / read / cancel"| tasks["relaykeep-tasks<br/>MCP server"]
    tasks --> queue[("file queue<br/>root/tasks/*.json")]
    worker["relaykeep-worker<br/>started by you, one pass"] --> queue
    worker --> providers["Claude Code CLI · Codex CLI<br/>Ollama (loopback) · echo"]

    subgraph optional["Optional, independent"]
        client2["MCP client"] -->|"stdio: search / read"| memory["relaykeep-memory<br/>read-only MCP server"]
        memory --> store[("JSONL file")]
    end
```

- **`relaykeep-tasks`** is a stdio MCP server with three tools: `submit`, `read` and `cancel`. It only writes to the queue. It never starts a worker or a model.
- **`relaykeep-worker`** runs a single pass over the queue and then exits. There is no daemon or scheduler. Run it by hand, or from a scheduler of your own.
- **`relaykeep-memory`** is a separate stdio MCP server with `search` and `read`. Neither server imports the other, so you can use either one on its own.

Both servers send short English instructions to the client when it connects (under 512 characters), describing the same rules as this README.

## What it does and doesn't do

| Area | What you get | Not included |
|---|---|---|
| Submit | `submit(client_request_id, payload)` returns `task_id` and `payload_sha256`. The same ID with the same payload returns the same receipt. The same ID with a different payload is a conflict. | Deduplication across different IDs |
| Read | `read(task_id, payload_sha256)` can be repeated as often as you like and returns the state, result and error. The hash checks that you are reading the payload you submitted; it is not authentication. | Push notifications, streaming, access control |
| Worker | Manual, one pass, `queued → running → complete / failed`. Tasks are visited in task-ID order, which is derived from a hash, not in submission order. Several workers can run at once: a per-task file lock means only one claims each task. | Daemon, FIFO ordering, retries, timeouts, fallback models |
| Cancel | Works on `queued` tasks only | Stopping a `running` task or its provider |
| Providers | Claude Code CLI, Codex CLI, Ollama over loopback HTTP, and a built-in echo for testing | Remote HTTP providers, choosing a different model automatically |
| Model identity | `requested_model` is recorded exactly as requested | Proof of which model actually answered (see below) |
| Memory | Read-only `search` (substring match, ordered by how often the query terms occur) and `read`, plus an admin import command | MCP write tools, embeddings or semantic search, deletion, access control |
| Transport | stdio only | Network MCP, authentication, isolation between users |

## Requirements

- A POSIX system (Linux or macOS). RelayKeep uses `fcntl` file locks.
- Python 3.13 or newer.
- `mcp==1.28.1` (the official MCP Python SDK) for the two MCP servers and the demo. This is the only SDK version that has been tested, and `pip install .` installs it. The queue CLI (`python -m relaykeep.slice`) and the memory import command use only the standard library.
- To delegate, you also need your own install of the Claude Code CLI, the Codex CLI or Ollama.

## Connecting an MCP client

After `python -m pip install .`, the virtual environment has three commands: `relaykeep-tasks` (MCP server), `relaykeep-memory` (MCP server) and `relaykeep-worker` (not an MCP server). Only the two servers go into a client configuration. Run the worker yourself, as described in [Delegating](#delegating-to-claude-code-codex-or-ollama).

Replace `/absolute/path/to/...` with real paths. RelayKeep does not edit any client configuration for you. File locations and accepted keys depend on your client version, so check its documentation.

**Claude Code, Claude Desktop and other clients that use `mcpServers` JSON:**

```json
{
  "mcpServers": {
    "relaykeep-tasks": {
      "command": "/absolute/path/to/relaykeep/.venv/bin/relaykeep-tasks",
      "args": ["--root", "/absolute/path/to/relaykeep-data"]
    }
  }
}
```

**Codex** (`config.toml`, for example `~/.codex/config.toml`):

```toml
[mcp_servers.relaykeep-tasks]
command = "/absolute/path/to/relaykeep/.venv/bin/relaykeep-tasks"
args = ["--root", "/absolute/path/to/relaykeep-data"]
```

After connecting, call `submit` with a JSON object payload, run the worker yourself, and then call `read` with the `task_id` and `payload_sha256` from the receipt. If you lose the receipt, submit the same `client_request_id` and payload again to get it back. Do not resubmit under a new ID while a task is `running` or its outcome is unknown.

## Using the queue without MCP

The queue also works from the command line, with no MCP client or model. If a payload has no `provider` key, the worker just echoes it back.

```bash
python -m relaykeep.slice submit --root /absolute/path/to/relaykeep-data \
  --id hello-1 --payload '{"message": "hello"}'
# {"created_at": ..., "payload_sha256": "<sha>", "task_id": "<task_id>"}

relaykeep-worker --root /absolute/path/to/relaykeep-data
# {"dispatched": ["<task_id>"], "invalid_records": [], "skipped": [], "unknown_running": [], "worker_pid": ...}

python -m relaykeep.slice read --root /absolute/path/to/relaykeep-data \
  --task-id '<task_id>' --sha '<sha>'
# {"error": null, "payload_sha256": "<sha>", "result": {"echo": {"message": "hello"}, "provider": "fake-echo"}, "state": "complete", ...}
```

Replace `'<task_id>'` and `'<sha>'` with the values from the submit output. Keep the quotes if you paste the placeholders as they are, because an unquoted `<` or `>` is a shell redirection.

Records are stored under `<root>/tasks/`. To cancel a queued task, use `python -m relaykeep.slice cancel --root /absolute/path/to/relaykeep-data --task-id '<task_id>' --sha '<sha>'`. This command exits 0 even when nothing was cancelled, so check the `cancelled` field in its output. Cancelling a task that is already `cancelled` changes nothing and returns `cancelled: true` again.

The `--payload` value of `submit`, including any prompt in it, is a command-line argument of that `submit` process, so other processes on the machine may be able to see it while it runs. The MCP `submit` tool does not put the payload on a command line.

`relaykeep-worker ARGS` is the same as `python -m relaykeep.slice worker ARGS`.

## Delegating to Claude Code, Codex or Ollama

The payload chooses the provider. For Claude and Codex, the payload must contain exactly these three keys:

```jsonl
{"provider": "claude", "model": "<model-id>", "prompt": "Reply with READY."}
{"provider": "codex",  "model": "<model-id>", "prompt": "Reply with READY."}
{"provider": "ollama", "model": "<model-name>", "prompt": "Reply with READY."}
```

The worker's own command-line options decide which executables run and in which directory. A payload cannot change them.

```bash
relaykeep-worker --root /absolute/path/to/relaykeep-data \
  --claude-executable /absolute/path/to/claude \
  --codex-executable /absolute/path/to/codex \
  --work-dir /absolute/path/to/project \
  --ollama-url http://127.0.0.1:11434
```

- Executable and work-directory paths must be absolute. There is no `PATH` lookup, and the work directory must already exist. If a path is missing, that task fails and nothing is started.
- The worker sends the prompt to the child CLI on stdin, never on the child's command line, and no shell is used. (This is about the worker and its child. `python -m relaykeep.slice submit --payload` still takes the payload as an argument; see [Using the queue without MCP](#using-the-queue-without-mcp).)
- Every task in one root runs with the same executables and `--work-dir`. Use a separate root, with its own worker, for each execution configuration or work directory.
- Workers on the same root can run tasks at the same time in the same work directory, and RelayKeep does not coordinate file edits. Run tasks that edit the same files one at a time, for example with a single worker for that root.
- For Claude and Codex, `model` must match a strict character pattern and can't start with `-`. The Ollama model name is sent in the JSON request body instead.
- The child CLI is an ordinary child process of the worker. It runs as the same OS user, inherits the worker's environment variables, and is subject to whatever OS-level restrictions apply to the worker process.
- RelayKeep runs `claude -p --output-format json --model <model>` and `codex exec --json --model <model> -`. It adds no permission, sandbox or approval flags. See [Security](#security).
- Ollama is called with plain `http` on a loopback address only (`127.0.0.1`, `localhost` or `::1`), without following proxies or redirects.

**Model identity.** For Claude and Codex, `model` is always `null` and `model_evidence.verified` is `false`, because neither CLI's output has a field for the model that served the response. Claude's `modelUsage` is saved as observed, and it may list helper models as well. For Ollama, the task fails unless the `model` in the response matches the requested model. That value is still reported by your local server, not independently verified.

## Optional memory server

The store is a JSONL file. Records can only be added with the admin command, which validates every row first. If an ID already exists with different content, the whole import is rejected.

```bash
python -m relaykeep.memory_store import \
  --store /absolute/path/to/memory.jsonl --fixture fixtures/synthetic_memory.jsonl
```

```json
{
  "mcpServers": {
    "relaykeep-memory": {
      "command": "/absolute/path/to/relaykeep/.venv/bin/relaykeep-memory",
      "args": ["--store", "/absolute/path/to/memory.jsonl"]
    }
  }
}
```

```toml
[mcp_servers.relaykeep-memory]
command = "/absolute/path/to/relaykeep/.venv/bin/relaykeep-memory"
args = ["--store", "/absolute/path/to/memory.jsonl"]
```

- The server never changes the records, but it takes a file lock through `<store>.lock` next to the store and may create that file. The store's directory must therefore be writable by the server.
- `search(query, limit=5)` returns the records whose text contains every whitespace-separated query term (case-insensitive substring match). Results are ordered by the total number of times the terms occur in each record, then by file order. This is a simple count, not semantic search. Each hit has only an ID, a short snippet and the source. `limit` must be between 1 and 20.
- `read(id)` returns the full record exactly as it was imported.
- Nothing is summarized, rewritten or added to a client's context automatically.

## Reliability notes

- **No exactly-once execution.** Each `client_request_id` is queued at most once, and a `running` task is never dispatched again. But the outcome of a task is not always knowable.
- **`unknown_running` is not proof of a crash.** A worker lists every record it finds in the `running` state under `unknown_running`. That includes tasks another worker is still running right now, as well as tasks left behind by a worker that died after claiming them. A task left behind that way stays `running`: nothing recovers it automatically, and `cancel` does not clear it. A provider process that has already started may keep running, and its output is lost.
- **Damaged records are left as they are.** A file under `<root>/tasks/` counts as damaged if it isn't valid JSON, lacks a required field, has a task ID that doesn't match its file name, or holds a payload that no longer matches its stored hash. RelayKeep never overwrites, dispatches or cancels a damaged record. `submit`, `read` and `cancel` for that ID fail with `InvalidRecord`. Each worker pass lists it, and any `.json` file with an invalid name, under `invalid_records` with a short reason and carries on with the other tasks. A record that becomes damaged while its task is running is only noticed when the worker saves the result. The worker then raises `InvalidRecord` and stops that pass without saving, so the other tasks in that pass are not processed and the damaged task's outcome is unknown. RelayKeep does not run that task again or retry it. Inspect damaged files yourself, and leave them in place. **Moving, renaming or deleting a damaged file frees its task ID**: a later `submit` with the same `client_request_id` creates a new queued record, and a worker can run the task again. Don't move or delete these files as a routine way to recover. The hash check catches accidental changes; it doesn't stop someone who can write the file and recompute the hash.
- **Storage errors stop the worker pass.** Errors reading a record file (other than a damaged record) or saving a result, such as a full disk, are not caught. A task whose result could not be saved stays `running` and is not dispatched again.
- **No timeouts.** A CLI that hangs blocks that worker until you stop it.
- **No output size limit.** CLI output is buffered in memory.
- Power loss and filesystem crashes have not been tested.

## Security

RelayKeep is designed for **one trusted user on one local POSIX machine**. Please read [SECURITY.md](SECURITY.md) before you point it at a real CLI or a real working directory. In short:

- **Approvals are not carried over from the front door.** Approvals given in the MCP client session that submitted the task are not automatically transferred to the child CLI. The child runs with its own native permissions and configuration, including settings the CLI has saved to disk, which it may read again. It also loads hooks, rules and MCP server settings from your home directory or `--work-dir`. It inherits the worker's OS user, environment and any OS-level restrictions on the worker process. Nobody is present to answer approval prompts.
- **Intended scope is one trusted user on a local POSIX machine.** There is no network MCP transport, authentication or isolation between users.
- **Prompt injection:** treat prompts, responses and memory records as untrusted input. Native hooks and project configuration in the work directory run as your user.
- **Model identity:** results are not proof of which model answered (see above).
- **Local files are not encrypted.** Payloads (including prompts), results and error text are stored as plain JSON. Error text is the exception type and message, and may include the end of CLI stderr and known stdout failure fields (up to 300 characters of text each). The message after the type name keeps up to 1,000 characters. Each cut adds a `[truncated]` marker and `...` on top of those limits, so the stored text can be slightly longer. The cap only limits length and does not remove secrets. There is no delete command or retention setting.
- **Cancel works on `queued` only, `unknown_running` needs manual review, and there is no exactly-once execution.**

The source has been read by LLM-based code reviewers (static review only). That is not a penetration test, a certification or an independent security audit, and RelayKeep has had none of those. How to report a vulnerability is also described in [SECURITY.md](SECURITY.md#reporting-a-vulnerability). Please don't put exploit details in a public issue.

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md) for the test commands and the rules a change must keep.

## Status and license

- **Version** `0.0.0.dev5`, pre-release. It is not an audited release, and it is not published on any package index.
- **Tested**: for the earlier `0.0.0.dev4` (commit `e4e2e53`), 79 unit tests passed with none skipped in [CI on Linux and macOS](https://github.com/tsunamayo7/relaykeep/actions/runs/35150943684). `0.0.0.dev5` has been tested locally only so far; its final test count is not recorded here, and no CI result is reported for it. The tests use synthetic stand-ins for the CLIs and make no real model calls.
- **Related projects**: see [docs/alternatives.md](docs/alternatives.md) for a short, dated comparison.
- **Checked with real tools, for `0.0.0.dev3`, on a single machine only**:
  - The Claude Code CLI (requested model `claude-opus-5`) and the Codex CLI (requested model `gpt-5.6-sol`) each completed a task with the expected `READY` reply. Reading the same task ID twice returned the same result.
  - A local Ollama task with `qwen3.8:27b` completed.
  - The CLI flags were checked against the help output of Claude Code 2.1.273 and Codex CLI 0.154.0.
- These checks don't cover every environment, CLI version or model.
- **License:** [MIT](LICENSE), Copyright (c) 2026 tsunamayo7. Dependencies such as `mcp` are not bundled and keep their own licenses.

TDQS

A4/5.0

Scored across 3 tools

Disambiguation5/5

Each tool maps to a distinct lifecycle operation: submit creates a task, read retrieves state and result, and cancel handles queued task cancellation. There is no meaningful overlap between the three actions.

Naming Consistency5/5

All tool names are single-word imperative verbs—submit, read, cancel—following a consistent and predictable pattern. While not verb_noun style, the convention is uniform across the entire server.

Tool Count5/5

Three tools is well-scoped for a task relay server: each tool covers a necessary operation and none are redundant. This is comfortably within the ideal 3-15 range.

Completeness5/5

The surface covers the core task lifecycle: submission, status/result reading, and cancellation of queued tasks. The inability to cancel running tasks is explicitly documented rather than an overlooked gap.

Maintenance

ActivityMaintained
ResponsivenessNo issues