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
Read the address off the Companion Memory app page. 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. In the Hub dashboard, open the Companion
Memory app and read the Access points card. It lists up to three routes, each with a copy button:
Local access, Private VPN, and Public domain. A route that is not set up shows
Not configured or Unavailable.
| How it runs | Address of the MCP endpoint |
|---|---|
| Installed from the Hub app store, on the LAN | http://<appliance-ip>:<host-port>/api/mcp (Local access) |
| Same, from the Hub machine itself | http://localhost:<host-port>/api/mcp |
| Over Tailscale, with Private VPN enabled | https://<hub>.<tailnet>.ts.net:<host-port>/api/mcp (Private VPN) |
| Exposed through a public domain | https://<your-domain>/api/mcp — no port (Public domain) |
| Desktop app, from the same computer | http://127.0.0.1:8642/api/mcp |
| Standalone, from Companion Memory’s compose | http://<host>:8642/api/mcp |
<host-port> is the port Hub published for the app. Hub tries the app’s own port, 8642, first, so
on a typical install it is 8642. If 8642 is already taken when you install, or you chose a
different port in the install form, Hub picks a free port in the 10000–60000 range instead, and
the number differs per appliance. Do not assume 8642 — the Local access value is authoritative.
This page does not link a compose file for the Standalone row, and
Companion’s source repositories are private. Treat a standalone install as a
route for people who already have that compose file; everyone else installs
Companion Memory from the Hub app store. The Desktop app serves only on
its own computer, always on port 8642 — see Install the desktop
app,
which has no public download yet. Its window can sign in with a device key
that cannot create API keys, so create the key in a browser at that address.
MCP endpoint
| URL | <memory-url>/api/mcp |
| Transport | Streamable HTTP |
| Protocol version | 2025-11-25 (older revisions negotiate down) |
| Auth | Authorization: Bearer <key> (or x-api-key) |
| Required scopes | MCP to connect, and Intents for tools |
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 reads 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 |
| Push observations in (capture agents) | Capture |
| GraphQL, or the graph routes | none — an unscoped, full-access key |
The same table as a picture — each surface opens only to the scope it declares, and the unscoped key is the only one that reaches the surfaces that declare none:
flowchart LR
K["Your Companion<br/>Memory key"] --> S{"Scopes ticked"}
S -->|"MCP + Intents"| M["MCP server<br/>/api/mcp — tools"]
S -->|"Memory"| R["REST — /api/memory/*<br/>recall, context, write,<br/>turn, compact"]
S -->|"Capture"| C["Bulk ingest<br/>/api/v1/* write routes,<br/>/api/files upload"]
S -->|"none — full access"| G["GraphQL and graph routes<br/>/api/graphql"]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.
Capture is the write-only lane for agents that stream observations in and read nothing back —
Companion Capture and the browser extension. It opens the /api/v1/* bulk-ingest endpoints
(events, communications, contents, things, browser-activity, notes, positions,
receipts, product-models, observations, plus inventory, a deprecated alias of things),
POST /api/files/upload, and PATCH /api/files/:id.
It deliberately opens nothing else — no /api/memory/*, no GraphQL.
Scopes are not additive permissions. Only a handful of surfaces declare a
scope — /api/mcp, the intent catalog and the pending-action routes
(Intents), /api/memory/*, /api/v1/intelligence/* (persona intelligence)
and /api/v1/agent/jobs (all Memory, so a Memory key reaches them too),
and the Capture write plane above. Ticking any box makes the key
scope-limited, which denies it everywhere no scope is declared: GraphQL, the
graph routes, and the batch-upload lane POST /api/v1/files (which, unlike
the rest of /api/v1, declares no scope at all — so even a Capture key is
refused there). Leaving every box unticked is the only way to reach those, and
it produces a full-access key. There is no combination that opens every row
above at once.
What an MCP client sees
A key with MCP and Intents sees Companion Memory’s curated intents, which the server advertises as
tools. Each intent’s dotted id becomes a tool name with _ in place of the dot, so memory.recall
appears as memory_recall. As of v2026.10.2 there are 36:
| Domain | Tools |
|---|---|
| Memory | memory_recall, memory_remember (needs write), memory_forget (needs full), persona_compose |
| Knowledge | knowledge_search, knowledge_preferences |
| People and places | people_find, people_relationships, places_find, places_next |
| Calendar and media | calendar_findEvents, media_findPhotos, media_entertainmentPreferences, media_streamingAvailability |
| Planning | planning_findTasks, planning_plannedBlocks, planning_createTask, planning_updateTask, planning_scheduleTask, planning_completeTask |
| Insights | brief_list, insights_explain, anomalies_recent, forecast_read, forecast_calibration, suggestions_list, suggestions_feedback |
| Messaging | messaging_listChats, messaging_listMessages, messaging_send |
| Import | import_list_sources, import_recognize_path, import_scan_inbox, import_run_status, import_import_path, import_import_inbox |
Capability filters that list: a read key sees 26 of the 36, a write key sees 32, and a full key
sees all 36. memory_forget, messaging_send, planning_completeTask, and import_import_inbox are
the four a write key does not get. Even a full key sees those four held for your
approval by default — see A full key still waits for
Approvals. The server also publishes the
catalog itself as the intents://catalog resource. A key with no scopes selected additionally sees a query_* or
mutation_* tool for every GraphQL field — 112 queries and 138 mutations as of v2026.10.2.
The operation shape in memory_remember
Every entry in operations is a discriminated union on a kind field. This is now advertised:
the tool’s input schema marks kind required and enumerates the four values, and the description
repeats them — so an agent working from tools/list alone can construct a valid call. (Earlier
releases published operations as an array of open-ended objects, which is why some clients still
guess.)
{
"operations": [
{
"kind": "entity.create",
"entityType": "Concept",
"name": "…",
"properties": { "category": "…" }
}
],
"dryRun": true
}entityType is free text as far as the database is concerned (any ^[A-Z][A-Za-z]*$ value inserts),
but only the graph’s own vocabulary is browsable afterwards. Stick to Person, Place, Concept,
Item, Event, Content, Communication, File, Group, Pet, Product or Tag — an invented
type still draws on the Graph view, as a neutral node with the default color and its own legend
entry (no type filter can exclude it either), but it is a dead end from there: no explorer type
filter maps to it, clicking it resolves to no record so the drawer falls back to the graph’s own
name/type card, and the List, Gallery and Map views cannot browse it.
Valid kind values are entity.create, entity.update, relationship.create, and
relationship.update. Entity operations are processed before relationship ones within a single
call, and a relationship.create can resolve from/to by a name minted earlier in the same batch
— but the entity write is queued, not applied inline, so the endpoint check that runs immediately
before the relationship is written normally finds nothing yet and the operation fails with “Both
source and target entities must exist and be owned by the user”. Create the entities in one call,
then relate them in a second. A dryRun returns before that check and so reports the same-call
pattern as succeeding.
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.
On Companion Memory v2026.9.28.1 and later, capability is enforced on every
route a key can reach. A route that declares a level requires it, and one that
declares nothing requires what its shape implies — a GraphQL mutation or any
non-GET request needs write, and a query or GET needs read. So read
holds even for a key with no scopes selected. On earlier releases GraphQL and
the batch-upload lane POST /api/v1/files declared neither a scope nor a
capability, so a read key with no scopes reached them unchecked; there,
select the areas your key needs.
CORS
Companion Memory allows only its own native-app origins by default (capacitor://localhost,
ionic://localhost, and bare http://localhost and https://localhost). No browser page on any other
origin can call these endpoints, so browser-based MCP clients and browser JavaScript do not work —
treat this as a server-side and CLI integration. An operator can add origins with the
CORS_ALLOWED_ORIGINS environment variable (comma-separated); it is empty out of the box. The
allowed request headers are Content-Type, Authorization, X-CSRF-Token, and X-API-Key, and the
only exposed response header is X-Thread-Id, so a browser cannot send or read Mcp-Session-Id even
from an allowed origin.