Companion Memory
Companion Memory is reachable three ways: an MCP server for agents, a REST API for memory operations, and a GraphQL API for search and structured queries.
One key can open MCP and REST together. GraphQL is different — scope enforcement is deny-by-default, so a key that has any scope ticked is refused everywhere no scope is declared, and GraphQL declares none. Reaching GraphQL means using a key with no scopes selected at all, which is a full-access key. See Which scopes for which job.
Find your address first
Companion Memory has no fixed port. Every example in this section writes its base URL as
<memory-url> because the real value depends on how your instance runs, and guessing it is the most
likely reason a correct config still fails to connect.
| How it runs | Address of the MCP endpoint |
|---|---|
| Installed from the Hub app store (usual) | http://companion-memory.localhost/api/mcp — no port |
| Same, reached by LAN IP | http://<appliance-ip>:<allocated-port>/api/mcp |
| Over a private VPN such as Tailscale | http://<appliance-vpn-name>:<allocated-port>/api/mcp |
| Exposed through a tunnel or custom domain | https://<your-domain>/api/mcp — no port |
| Standalone, from Companion Memory’s compose | http://<host>:8642/api/mcp |
The .localhost suffix is Hub’s default local domain. If you set LOCAL_DOMAIN, substitute yours — see Finding Your App’s URL.
8642 is the port inside the container, not on your appliance. When Hub
installs Companion Memory it publishes it on a host port it allocates itself,
from a wide range — so the number differs per appliance and is not 8642. If
you have seen 8642 quoted for a Hub install, that is where the
connection-refused is coming from.
Take the address from the Hub dashboard instead: the Companion Memory tile shows its URL, and Open on local network gives you the LAN form with the correct port already filled in.
MCP endpoint
| URL | <memory-url>/api/mcp |
| Transport | Streamable HTTP |
| Protocol version | 2025-11-25 |
| Auth | Authorization: Bearer <key> (or x-api-key) |
| Required scopes | MCP and Intents |
The path is /api/mcp. A request to /mcp returns 405 Method Not Allowed
— the gateway routes ^/api/mcp only. Older versions of these docs published
the wrong path, so if you followed one of those, this is why.
A key with MCP but not Intents connects successfully and shows zero
tools. This is the most common problem people hit here, and it is a scope
choice rather than a fault. See the scope
trap.
REST API
The memory endpoints live under /api/memory and require the Memory scope — which is not
the same scope MCP needs. A key minted for MCP alone returns 403 here.
Scope is only half of it: these routes also declare a capability, and falling short of it is a 403 too.
| Method | Path | Purpose | Capability |
|---|---|---|---|
GET | /api/memory/context | Bootstrap context for a new session | read |
POST | /api/memory/recall | Retrieve memories relevant to a query | read |
POST | /api/memory/write | Store a memory explicitly | write |
POST | /api/memory/turn | Record a conversation turn | write |
POST | /api/memory/compact | Compact accumulated context | write |
A read key with the Memory scope reaches the first two rows and gets
403 on the other three — “This API key is “read” and cannot perform this
action, which requires “write” access”. Scope and capability produce the same
status code here, so check both before concluding the scope is wrong.
macOS / Linux / WSL2
curl -s -X POST "<memory-url>/api/memory/recall" \
-H "Authorization: Bearer <memory-key>" \
-H 'Content-Type: application/json' \
-d '{"query":"what did I decide about the database migration"}'GraphQL API
Search and structured queries go through GraphQL rather than a REST search endpoint.
GraphQL declares no scope, and a scope-limited key is refused on every route
that declares none. So the MCP + Intents + Memory key the rest of this
page tells you to mint is rejected here with “This API key is scope-limited
and cannot access this endpoint”. Expect that message inside the GraphQL
errors array rather than as an HTTP error status — a client that only checks
the status code will read the call as having succeeded. Only a key with no
scopes selected reaches GraphQL, and that key is full-access by
construction.
macOS / Linux / WSL2
# <unscoped-key> = a key created with no scopes ticked. A scoped key is refused here.
curl -s -X POST "<memory-url>/api/graphql" \
-H "Authorization: Bearer <unscoped-key>" \
-H 'Content-Type: application/json' \
-d '{"query":"query($input: SearchInput!) { search(input: $input) { __typename } }","variables":{"input":{"query":"database migration"}}}'There is no /api/search endpoint. Earlier documentation referenced one; it
never existed.
Which scopes for which job
| What you want | Scopes to select |
|---|---|
| MCP tools only | MCP + Intents |
| REST memory only | Memory |
| MCP tools and direct REST calls | MCP + Intents + Memory |
| GraphQL, bulk ingest, or files | none — an unscoped, full-access key |
This trips people up because MCP and REST are genuinely separate surfaces with separate scopes. If
your agent connects over MCP but every /api/memory/* call returns 403, the key is missing Memory.
Scopes are not additive permissions. Only three surfaces declare a scope —
/api/mcp, the intent catalog, and /api/memory/*. Ticking any box makes the
key scope-limited, which denies it everywhere no scope is declared: GraphQL,
bulk ingest, files, and the graph routes. Leaving every box unticked is the
only way to reach those, and it produces a full-access key. There is no
combination that opens all four rows above at once.
A gap worth knowing about in memory_remember
The tool’s advertised input schema describes operations as an array of open-ended objects. The
server actually validates each entry as a discriminated union on a kind field, which the schema
does not mention:
{
"operations": [
{
"kind": "entity.create",
"entityType": "Note",
"name": "…",
"properties": { "body": "…" }
}
],
"dryRun": true
}Valid kind values are entity.create, entity.update, relationship.create, and
relationship.update.
An agent working only from tools/list cannot construct a valid call — it has to fail once and read
the error to learn the contract. Use "dryRun": true while you work out the shape; it checks the
operation’s shape and reports what would happen without writing anything.
dryRun does not lower the bar to use the tool. memory_remember is a
write tool, and capability is checked before the arguments are looked at —
so a read key does not see it in tools/list at all, and naming it directly
returns “requires “write” access, but this API key is “read"". Experimenting
with dryRun needs a write key.
dryRun validates shape, not references. It returns early before the checks
that the entities a relationship.create points at actually exist and belong
to you, so a dry run can report created for an operation that fails for
real. Treat a clean dry run as “the payload parses”, not “the write will
succeed”.
Capability
Beyond scopes, each key carries a capability — read, write, or full — that decides what it may
do on the surfaces it can reach. See
Choosing a capability.
Capability is enforced on routes that declare a scope. A key with no scopes
selected is not scope-limited, so it reaches GraphQL and bulk ingest where
nothing checks capability — meaning read does not fully hold for that one
combination. Select the areas your key needs.
CORS
Companion Memory sends no CORS headers. Browser-based MCP clients and browser JavaScript cannot call these endpoints directly — this is a server-side and CLI integration.