<!-- description: What an MCP attachment costs in base64: the encoded file sits in the agent's context for the whole session. The AgentRQ workspace CLI keeps it out. -->
<!-- date: 2026-09-17 -->
<!-- author: AgentRQ Team -->
<!-- ogimage: https://agentrq.com/assets/blog/base64-cost-of-mcp-attachments-og.png -->

# MCP Attachment Base64 Cost: Why AgentRQ Has a Workspace CLI

An agent talks to its AgentRQ workspace over MCP. That is the right shape for an agent — typed tools, JSON arguments, a transport it already speaks — and the wrong shape for almost everything else. Every call is an envelope, and every answer is tokens somebody pays for.

`agentrq-ws` is the same eleven tools, from a shell:

```bash
npx @agentrq/agentrq-ws@latest workspace
```

No install, no configuration, no token to paste. It reads the `.mcp.json` in the directory you run it from — the same file the agent working there uses — so inside a workspace checkout there is nothing to set up. It is published on npm as [@agentrq/agentrq-ws](https://www.npmjs.com/package/@agentrq/agentrq-ws), Apache-2.0, and it shipped in [PR #589](https://github.com/agentrq/agentrq/pull/589).

The full reference is in the [Workspace CLI docs](/docs/workspace-cli). This post is about why it exists.

![A terminal running agentrq-ws against the workspace this post was written in: the workspace command printing the title, mission and task statistics, and the tools command piped into head](/assets/blog/base64-cost-of-mcp-attachments-terminal.png)

That is a real run against the workspace this post was written in. Eleven tools, no token pasted anywhere — the `.mcp.json` in the repository already had one.

## Where the Cost Comes From

Start with the thing that is worst about doing this from inside an agent, because it is the reason the CLI is not just a thinner MCP client.

The workspace tools speak base64 in both directions. `createTask` and `reply` take an `attachments` array whose entries look like `{id, filename, mimeType, data}`, and `data` is the whole file, base64-encoded. `downloadAttachment` answers with base64 and nothing else — no name, no media type, just the encoded bytes.

So when an agent attaches a build log today, this is what actually happens:

1. It reads the file off disk, into its own context.
2. It base64-encodes it — which means the encoded text is also in its context.
3. It puts that text in the arguments of a tool call.
4. The tool call and its arguments stay in the conversation for the rest of the session, re-sent with every subsequent request.

Step 4 is the one that hurts. A tool call is not a cost you pay once. It is part of the transcript, so a 2 MB screenshot attached at the start of a session is still being re-sent at the end of it.

And base64 is not free even before that. It encodes three bytes as four characters, so the encoded form is **four-thirds the size** of the file: a 900 KB PDF becomes 1.2 MB of text, a 3 MB screenshot becomes 4 MB. That text is a dense run of alphanumerics with no words in it, which is close to the worst case for any tokenizer — nothing in it compresses the way prose does.

Downloading is the same tax in reverse. The agent asks for an attachment, and the answer — the entire encoded file — lands in its context, where it stays.

## What the CLI Does Instead

```bash
agentrq-ws reply 0isnjTCkpW5 "Build is green — log attached" --attach ./run.log
```

The encoding happens in the Node process. `readAttachment` reads the path, works out the media type from the extension, base64-encodes the bytes, and hands the result straight to the tool call. The agent's context holds one line of shell and whatever the server says back.

Downloading writes a file and prints where it went:

```
$ agentrq-ws attachment get 0isp9dJxr85 --task 0isnjTCkpW5
/tmp/agentrq-ws-help.txt
```

One path, not four megabytes of alphabet. `--out` takes a directory to keep the attachment's own name, or a full path to rename it.

There is a detail worth pointing at, because it is the kind of thing that decides whether a tool is pleasant. `downloadAttachment` returns bytes and no filename — so where does `agentrq-ws-help.txt` come from? The CLI reads the task first, indexes the attachments named in its text and its conversation, and looks the id up. The file arrives called what a human called it. If the task does not mention the id, the download still happens, named after the id, because refusing would be worse than a plainly-named file.

## What This Unlocks

**Attachments stop being rationed.** When every file costs context that is never reclaimed, the rational thing for an agent to do is describe a log instead of attaching it, summarize a screenshot instead of sending it, and paste the first forty lines of a stack trace. That is a quiet, constant loss of fidelity, and it happens at exactly the moments when fidelity matters — when something has gone wrong and somebody needs the actual artifact. With the bytes out of the transcript, attaching the whole log is a line of shell, so an agent can hand over the real thing every time.

**Anything with a shell can drive a workspace.** The CLI needs no MCP client, no SDK and no long-lived process — just `npx` and a `.mcp.json`. That makes a workspace reachable from places an agent cannot go: a CI job that replies to a task when the build finishes, a cron entry that publishes an event, a git hook that opens a task on a failed push, a `Makefile` target. `task next` claims the next not-started task and dequeues it, so a shell loop is a perfectly good worker.

**Humans get the same surface as the agent.** Debugging a workspace used to mean either clicking through the panel or standing up an MCP client. Now it is `agentrq-ws task get <id> --conversation`, and the answer is plain text. When an agent and a person are looking at the same workspace, they can now use the same commands to look at it, which makes "what did it actually see?" a question with a fast answer.

**A tool added to the server tomorrow is reachable today.** `agentrq-ws call <tool> --args '{…}'` calls anything the server advertises, and `agentrq-ws tools` lists what that is. The CLI does not have to grow a verb before a new capability is usable from a shell.

## The Commands

Fourteen commands over the workspace's eleven tools. Every one takes `--help`, and so does every family — `agentrq-ws task --help` lists the task commands, `agentrq-ws attachment get -h` explains that one.

| Command | What it does |
| --- | --- |
| `workspace` | Show the workspace title and mission |
| `task get <taskId>` | Fetch a task, with `--conversation` for its history |
| `task next` | Take the next not-started task — this dequeues the queue |
| `task create <title>` | Create a task, with `--body`, `--attach`, `--cron`, `--event` |
| `task status <taskId> <status>` | Set a status: notstarted, ongoing, completed, rejected, cron, blocked |
| `reply <taskId> <text>` | Send a message to a task, with `--attach` |
| `attachment get <id> --task <taskId>` | Download an attachment to a file |
| `memory load [name]` | Read a workspace memory, the index by default |
| `memory save [name] --content …` | Replace a workspace memory |
| `memory delete [name]` | Delete a workspace memory |
| `event publish <name>` | Publish a named event, with `--payload` and `--faq` |
| `ask <taskId> <message>` | Ask the human a question and wait for the answer |
| `tools` | List the tools this workspace server offers |
| `call <tool> --args '{…}'` | Call any tool directly |

`task next` is a separate verb rather than a bare `task get` on purpose: it claims work. A command that mutates the queue should say so in its name.

## Long Text Is a File, Not an Argument

Task bodies, memory contents and event payloads are routinely longer than a shell argument wants to be. Anywhere prose is expected — `--body`, `--content`, `--payload`, and a reply's text — `@path` reads a file and `-` reads stdin:

```bash
agentrq-ws task create "Post-mortem" --body @notes.md
git log --oneline -20 | agentrq-ws reply 0isnjTCkpW5 -
agentrq-ws memory save release-notes.md --content @CHANGELOG.md
```

That last one matters more than it looks. Workspace memory is what a workspace remembers between sessions, and being able to write it from a file means the memory can be generated, reviewed in a diff, and committed, instead of being typed into a tool call.

## Asking a Human, From a Script

`ask` is the `elicit` tool, which blocks until the human answers or the timeout elapses — an hour by default, and an hour at most. Two shapes:

```bash
agentrq-ws ask 0isnjTCkpW5 "Which branch should I release from?" \
  --field branch:string:"Branch name" --field sign:boolean:"Sign the tag?"

agentrq-ws ask 0isnjTCkpW5 "Approve the deploy, then confirm" --url https://example.com/approve
```

The `--field name:type:description` shorthand builds the form schema, because the protocol restricts these to a flat object of primitives, which is what makes describing one on a command line reasonable at all. `--schema @file` is there for anything the shorthand cannot say. The mode is inferred from which of the two you gave.

This makes a human approval step available to a shell script. A release script can stop, ask the person whose decision it is, and carry on with the answer.

## How It Connects

The CLI walks up from the working directory looking for a `.mcp.json`, the way `git` looks for a repository — an agent's working directory is often a subdirectory of the one holding the config, and stopping at the first level would refuse to work from exactly the places people run it from.

When that file defines several servers, an unambiguous single entry is used, an `agentrq` name is a strong hint, and anything still ambiguous is an error that lists the names rather than a guess. Connecting to the wrong server would act on the wrong workspace, which is not a thing to be quietly wrong about. `--server`, `--config`, and the `AGENTRQ_WS_URL` and `AGENTRQ_WS_SERVER` environment variables override all of it.

The MCP client underneath is hand-rolled rather than the SDK, and deliberately: this is a tool people run with `npx`, and three POSTs of handshake are cheaper to install and to audit than a dependency tree. There are no runtime dependencies at all. Node 20.6 or newer.

Two things in it were found by running it against a live workspace rather than by reading the spec, and both are the sort of thing a summary would leave out:

**Sessions can be evicted mid-command.** The server keeps sessions alive by pinging them over the SSE GET stream. A CLI never opens that stream, so it can never pong, and a two-call command like `attachment get` — read the task, then fetch the bytes — is long enough to get caught. The client re-establishes and retries once, and ends its session on exit rather than leaking one per invocation.

**`| head` is not an error.** Piping into a reader that stops early raised an unhandled `EPIPE` and turned an ordinary shell idiom into a stack trace and a failed exit. Closing the pipe is the reader's decision, so the CLI exits zero.

Output is the server's text by default. `--json` prints the raw result for piping into `jq`:

```bash
agentrq-ws tools --json | jq -r '.[].name'
```

Failures print one line and exit non-zero. A stack trace means a bug in the CLI, not a mistake in the command.

## Checked, Not Claimed

147 tests, no network, driving the whole path — argv parsing, config discovery, the MCP handshake, SSE framing, files on disk — with only the socket replaced. 100% of lines and functions are covered, and that is enforced in CI by `--test-coverage-lines=100 --test-coverage-functions=100`, so it is a build failure rather than a README badge.

```bash
cd cli/agentrq-ws
npm test
```

## Try It

```bash
cd ~/code/my-workspace
npx @agentrq/agentrq-ws@latest help
npx @agentrq/agentrq-ws@latest workspace
```

To keep it around, `npm install -g @agentrq/agentrq-ws`.

The [Workspace CLI reference](/docs/workspace-cli) has every command, every flag and the environment variables. The [MCP tools reference](/docs/mcp-tools) documents the tools underneath it, and [Workspaces](/docs/workspaces) covers where the `.mcp.json` and its token come from.
