relaykeep-tasks
# RelayKeep
**English** | [日本語](README.ja.md)
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. 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.dev3`) project for one person on one machine. It is not published on PyPI or any other package index.
## Contents
- [How it works](#how-it-works)
- [What it does and doesn't do](#what-it-does-and-doesnt-do)
- [Requirements](#requirements)
- [Quickstart](#quickstart)
- [Connecting an MCP client](#connecting-an-mcp-client)
- [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)
- [Status and license](#status-and-license)
## 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.
## 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) and `read`, plus an admin import command | MCP write tools, embeddings, ranking, 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. Tests have only been run on CPython 3.13.13.
- `mcp==1.28.1` (the official MCP Python SDK) for the two MCP servers. This is the only SDK version that has been tested. 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.
## Quickstart
Run these from the repository root. Replace `/absolute/path/to/...` with real paths.
```bash
python3.13 -m venv .venv
. .venv/bin/activate
pip install "mcp==1.28.1"
```
Try the queue without any 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>"}
python -m relaykeep.slice worker --root /absolute/path/to/relaykeep-data
# {"dispatched": ["<task_id>"], "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.
If the package is installed (for example from a locally built wheel), the same commands are also available as `relaykeep-worker`, `relaykeep-tasks` and `relaykeep-memory`. `relaykeep-worker ARGS` is the same as `python -m relaykeep.slice worker ARGS`.
## Connecting an MCP client
Many MCP clients accept an `mcpServers` JSON entry like the one below. The file location and the exact keys depend on your client, so check its documentation.
**Running from a checkout** (the interpreter must have `mcp` installed). `PYTHONPATH` is the checkout directory that *contains* the `relaykeep/` package folder, not the package folder itself:
```json
{
"mcpServers": {
"relaykeep-tasks": {
"command": "/absolute/path/to/.venv/bin/python",
"args": ["-m", "relaykeep.mcp_server", "--root", "/absolute/path/to/relaykeep-data"],
"env": { "PYTHONPATH": "/absolute/path/to/checkout" }
}
}
}
```
**Running the installed console script:**
```json
{
"mcpServers": {
"relaykeep-tasks": {
"command": "/absolute/path/to/.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.
## Delegating to Claude Code, Codex or Ollama
The payload chooses the provider. For Claude and Codex, the payload must contain exactly these three keys:
```json
{"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
```
From a checkout, `python -m relaykeep.slice worker` takes the same options.
- 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 prompt is sent on stdin, never on the command line, and no shell is used.
- 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/.venv/bin/relaykeep-memory",
"args": ["--store", "/absolute/path/to/memory.jsonl"]
}
}
}
```
From a checkout, use `"command": "/absolute/path/to/.venv/bin/python"` with `"args": ["-m", "relaykeep.memory_mcp_server", "--store", "/absolute/path/to/memory.jsonl"]` and the same `PYTHONPATH` as above.
- 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 query term (case-insensitive). 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.
- **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 may include CLI stderr and known stdout failure fields (up to 300 characters each) and Python exception text, and it is not scrubbed for 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.
## Status and license
- **Version** `0.0.0.dev3`, pre-release. It is not an audited release, and it is not published on any package index.
- **Tested**: 72 unit tests pass on CPython 3.13.13 with `mcp` 1.28.1. The tests use synthetic stand-ins for the CLIs and make no real model calls.
- **Checked with real tools, 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
Scored across 3 tools
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.
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.
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.
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.