Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ All notable changes to Engraphis are documented here. Format loosely follows

## [Unreleased]

### Added
- **Agent Connect (Team): connect agents to a hosted instance instead of running locally.**
Team members mint a per-user API token from the dashboard and point their agent at the
instance's HTTP API to store memories in the cloud v2 store (the same DB the dashboard
reads). New: `POST /api/remember` (Team-gated, mirrors the local `engraphis_remember`
MCP tool's params), per-user bearer-token endpoints (`POST /api/auth/token`,
`GET /api/auth/tokens`, `DELETE /api/auth/token/{id}`), and `GET /api/auth/connect-info`
to verify a token + discover the API base. The dashboard auth gate now accepts a
per-user bearer token exactly like a cookie session (bound to the member for role +
personal-folder authz); disabled members' tokens are refused instantly. Tokens are
SHA-256 hashed at rest (raw token shown once). A free / lapsed instance returns `402`
on `/api/remember`, so a Team license is required to host team agents. See
`docs/AGENT_CONNECT.md`. (`tests/test_agent_connect.py`.)
*Note:* MCP-over-HTTP at `/mcp` is deferred to a follow-up (needs a service-injection
refactor of `mcp_server.py` to avoid a second SQLite writer); agents use the HTTP API.

## [0.9.5] - 2026-07-14

### Changed
Expand Down
114 changes: 114 additions & 0 deletions docs/AGENT_CONNECT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Agent Connect — point your agent at a hosted Engraphis instance

Team members can connect their coding agents (Claude Code, Cursor, any HTTP-capable
agent) **directly to a hosted Engraphis dashboard** and store memories in the cloud
instance instead of running Engraphis locally. A Team license (the instance's) is
required to write — a free / lapsed instance refuses agent writes with `402`.

This is the team counterpart to the local-first model in [SYNC.md](./SYNC.md): instead
of each member running a local MCP server + syncing, one admin hosts a single instance
(e.g. on Railway) and everyone else just connects.

## How it works

1. **Admin** deploys one instance (see the Dockerfile / `docker compose up`) and activates
a **Team license** (Settings → License → paste key). The Team key sets the seat cap.
2. **Admin** invites members (Add member → email + password). Each member is a seat.
3. **Member** signs in at the dashboard URL (e.g. `https://team.engraphis.com/`) with
email + password — no key, no local install. (Login is never license-gated, so a lapsed
key never locks people out of the UI.)
4. **Member** opens **Settings → API tokens → Create token**, copies the bearer token.
5. **Member** configures their agent to call the instance's HTTP API with that token.

## Agent authentication

Agents authenticate with a **per-user bearer token** (`Authorization: Bearer <token>`),
minted from the dashboard. The token is bound to the member: their role, their personal
folders, and their seat. Disabling the member instantly invalidates their token.

Token management (requires a browser session):

| Method | Path | Purpose |
|---|---|---|
| `POST` | `/api/auth/token` `{label}` | Mint a token (raw token returned **once**) |
| `GET` | `/api/auth/tokens` | List your tokens (never includes the raw token) |
| `DELETE` | `/api/auth/token/{id}` | Revoke one of your tokens |
| `GET` | `/api/auth/connect-info` | Verify a token + discover the API base / snippet |

`GET /api/auth/connect-info` works with either a cookie or a bearer, so an agent can hit
it first to confirm its token is valid and learn the base URL:

```bash
curl -H "Authorization: Bearer <token>" https://team.engraphis.com/api/auth/connect-info
```

## The agent write/read API

| Method | Path | Notes |
|---|---|---|
| `POST` | `/api/remember` | **Team-gated** (402 without a Team license). Same params as the local `engraphis_remember` MCP tool. |
| `GET` | `/api/recall?q=…&workspace=…` | Read (not gated). |
| `GET` | `/api/memory/{id}?workspace=…` | One memory. |
| `GET` | `/api/why?q=…&workspace=…` / `/api/timeline?…` | Provenance / history. |

`POST /api/remember` body (all optional except `content`):

```json
{
"content": "We use pnpm for all frontend repos.",
"workspace": "default",
"repo": null,
"mtype": "semantic",
"scope": "repo",
"title": "",
"importance": 0.0,
"keywords": null,
"metadata": null,
"source": "agent",
"trusted": true,
"dedupe": true
}
```

Example:

```bash
curl -X POST https://team.engraphis.com/api/remember \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"content":"Redis caches the gateway.","workspace":"default"}'

curl "https://team.engraphis.com/api/recall?q=Redis&workspace=default" \
-H "Authorization: Bearer <token>"
```

Writes go to the **same v2 store the dashboard reads** — there is no separate agent DB,
so memories written by an agent immediately appear in the UI and in every other member's
recall (subject to workspace / personal-folder scoping).

## "They need a Team license to connect"

The Team license is the **instance's** (the admin activates one key; the relay enforces
the seat cap server-side). Members never present a license to log in or connect — they
present a **seat** (an account) and a **token**. The `402` on `POST /api/remember` fires
only when the **instance** has no active Team key (or it has lapsed / been revoked), which
is exactly "a Team license is required to host team agents."

## Security notes

- Tokens are stored **SHA-256 hashed** (like session cookies); a leaked users DB contains
no usable bearer secrets. The raw token is shown **once** at creation.
- Disable a member → their tokens stop resolving immediately (no need to revoke each).
- Expose the instance over **HTTPS** only (the session/token cookies and bearer tokens
must not transit cleartext). Behind Railway/a proxy, set
`ENGRAPHIS_FORWARDED_ALLOW_IPS=*` so the `Secure` flag is applied.
- The agent endpoints are rate-limit candidates for high-write deployments (the trial
endpoint already rate-limits; mirror that pattern if you expose this publicly).

## MCP-over-HTTP (`/mcp`)

A streamable-HTTP MCP endpoint at `/mcp` (so MCP-native agents point one URL at the cloud
instance) is planned but **not yet mounted** — mounting it as-is would spin up a second
writer to the same SQLite (WAL lock contention). It needs a service-injection refactor of
`engraphis/mcp_server.py` so it shares the dashboard's `MemoryService`. Until then, agents
use the HTTP API above (most agents can call HTTP via a tool/MCP-shell).
11 changes: 10 additions & 1 deletion engraphis/dashboard_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,16 @@ async def _auth_gate(request: Request, call_next):
if team_enabled and auth_store is not None and licensing.has_feature("team"):
from engraphis.inspector.auth import min_role, role_at_least
from engraphis.routes.v2_team import _COOKIE
user = auth_store.resolve_session(request.cookies.get(_COOKIE, ""))
# Agent connect: a per-user API bearer token (minted via POST /api/auth/token)
# authenticates exactly like a cookie session, but for headless agents. Try it
# first so an agent with no cookie is still bound to its member identity, then
# fall back to the browser session cookie. Either way the resolved member is
# bound via set_current_user so personal-folder ownership holds on every
# workspace-scoped read/write.
supplied = (request.headers.get("Authorization") or "").removeprefix("Bearer ").strip()
user = auth_store.resolve_api_token(supplied) if supplied else None
if user is None:
user = auth_store.resolve_session(request.cookies.get(_COOKIE, ""))
if user is None:
return JSONResponse({"error": "authentication required", "auth": "team"},
status_code=401)
Expand Down
67 changes: 67 additions & 0 deletions engraphis/inspector/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,20 @@
used INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_reset_user ON password_resets(user_id);
-- Per-user API tokens (agent connect): a Team member mints a long-lived bearer token
-- from the dashboard and pastes it into their agent's config. Only the SHA-256 hash is
-- stored (like session tokens), so a leaked users DB contains no usable secrets.
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
label TEXT NOT NULL DEFAULT '',
token_hash TEXT NOT NULL UNIQUE,
created_at REAL NOT NULL,
last_used_at REAL,
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_api_token_user ON api_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_api_token_hash ON api_tokens(token_hash);
CREATE TABLE IF NOT EXISTS audit_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts REAL NOT NULL,
Expand Down Expand Up @@ -462,6 +476,59 @@ def revoke_user_sessions(self, user_id: str) -> None:
self.conn.execute("DELETE FROM auth_sessions WHERE user_id=?", (user_id,))
self.conn.commit()

# ── per-user API tokens (agent connect) ─────────────────────────────────────
def create_api_token(self, user_id: str, *, label: str = "") -> dict:
"""Mint a long-lived per-user bearer token for an agent/automation client.

The raw token is returned ONCE; only its SHA-256 hash is persisted (see
:data:`api_tokens`), so a stolen users DB yields no usable secrets. Bound to
``user_id``; a disabled user's tokens are refused by :meth:`resolve_api_token`.
"""
tok = secrets.token_urlsafe(32)
tid = "tok_" + secrets.token_hex(8)
now = time.time()
label = (label or "")[:120]
self.conn.execute(
"INSERT INTO api_tokens (id, user_id, label, token_hash, created_at) "
"VALUES (?,?,?,?,?)", (tid, user_id, label, _hash_token(tok), now))
self.conn.commit()
return {"id": tid, "label": label, "created_at": now,
"last_used_at": None, "revoked": 0, "token": tok}

def resolve_api_token(self, token: str) -> Optional[dict]:
"""Resolve a bearer API token to its user, or ``None``. Rejects revoked tokens
and tokens whose owner is disabled. Best-effort stamps ``last_used_at``."""
if not token:
return None
row = self.conn.execute(
"SELECT t.id, t.user_id, t.revoked, u.disabled FROM api_tokens t "
"JOIN users u ON u.id = t.user_id WHERE t.token_hash=?",
(_hash_token(token),)).fetchone()
if row is None or row["revoked"] or row["disabled"]:
return None
try:
self.conn.execute("UPDATE api_tokens SET last_used_at=? WHERE id=?",
(time.time(), row["id"]))
self.conn.commit()
except sqlite3.Error:
pass
return self.get_user(row["user_id"])

def list_api_tokens(self, user_id: str) -> list:
rows = self.conn.execute(
"SELECT id, label, created_at, last_used_at, revoked FROM api_tokens "
"WHERE user_id=? ORDER BY created_at DESC", (user_id,)).fetchall()
return [dict(r) for r in rows]

def revoke_api_token(self, user_id: str, token_id: str) -> bool:
"""Revoke one of *user_id*'s own tokens (scoped to the caller so a member can't
revoke another member's). Returns True if a row was affected."""
cur = self.conn.execute(
"UPDATE api_tokens SET revoked=1 WHERE id=? AND user_id=? AND revoked=0",
(token_id, user_id))
self.conn.commit()
return cur.rowcount > 0

# ── team audit log ─────────────────────────────────────────────────────────
def record_event(self, action: str, *, actor_id: Optional[str] = None,
actor_email: Optional[str] = None, target: Optional[str] = None,
Expand Down
33 changes: 33 additions & 0 deletions engraphis/routes/v2_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,39 @@ def merge(req: _MergeReq):
title=req.title, mtype=req.memory_type, reason=req.reason)


# ── agent connect (Team) ───────────────────────────────────────────────────────
# The remote agent write path. An agent authenticates with a per-user bearer token
# (POST /api/auth/token) and stores memories on THIS cloud instance's v2 store — the
# same DB the dashboard reads — instead of running Engraphis locally. Gated by the
# instance's Team license (``_paid('team')``) so a free / lapsed instance can't host
# team agents: 402 without it. Workspace scoping + personal-folder ownership come from
# the auth gate's ``set_current_user`` (see dashboard_app._auth_gate). Parameters mirror
# the local MCP ``engraphis_remember`` tool so an agent gets identical semantics whether
# it writes locally or to the cloud.
class _RememberReq(BaseModel):
content: str
workspace: str = "default"
repo: Optional[str] = None
mtype: str = "semantic"
scope: str = "repo"
title: str = ""
importance: float = 0.0
keywords: Optional[list] = None
metadata: Optional[dict] = None
source: str = "agent"
trusted: bool = True
dedupe: bool = True


@router.post("/remember")
def remember(req: _RememberReq):
_paid("team")
return _run(service().remember, req.content, workspace=req.workspace,
repo=req.repo, mtype=req.mtype, scope=req.scope, title=req.title,
importance=req.importance, keywords=req.keywords, metadata=req.metadata,
source=req.source, trusted=req.trusted, resolve_conflicts=req.dedupe)


# ── consolidate ───────────────────────────────────────────────────────────────
class _ConsolidateReq(BaseModel):
workspace: Optional[str] = None
Expand Down
62 changes: 62 additions & 0 deletions engraphis/routes/v2_team.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ class DelUserReq(BaseModel):
user_id: str


class TokenReq(BaseModel):
label: str = Field(default="", max_length=120,
description="A memorable name for this agent token (e.g. 'claude-code-laptop').")


def _enabled() -> bool:
return os.environ.get("ENGRAPHIS_TEAM_MODE", "").lower() in {"1", "true", "yes", "on"}

Expand Down Expand Up @@ -380,5 +385,62 @@ def overview(request: Request):
"activity": store.action_counts(),
"events_total": store.count_events()}

# ── per-user API tokens (agent connect) ─────────────────────────────────────
# A signed-in member mints a long-lived bearer token here (from the dashboard UI)
# and pastes it into their agent's config. The token authenticates the agent to
# /api/* exactly like a cookie session would (see dashboard_app._auth_gate), bound
# to that member for personal-folder authz and role enforcement. The Team-license
# gate itself lives on the agent endpoints (e.g. POST /api/remember -> _paid('team')).

@router.post("/token")
def create_token(body: TokenReq, request: Request):
u = _require(request, "viewer")
row = store.create_api_token(u["id"], label=body.label)
ip = request.client.host if request.client else None
store.record_event("api_token.created", actor_id=u["id"], actor_email=u["email"],
detail=row["label"] or "(unlabelled)", ip=ip)
# ``token`` is returned ONCE; list_api_tokens below never includes it.
return row

@router.get("/tokens")
def list_tokens(request: Request):
u = _require(request, "viewer")
return {"tokens": store.list_api_tokens(u["id"])}

@router.delete("/token/{token_id}")
def revoke_token(token_id: str, request: Request):
u = _require(request, "viewer")
ok = store.revoke_api_token(u["id"], token_id)
if not ok:
raise HTTPException(status_code=404, detail={"error": "token not found"})
ip = request.client.host if request.client else None
store.record_event("api_token.revoked", actor_id=u["id"], actor_email=u["email"],
target=token_id, ip=ip)
return {"ok": True}

@router.get("/connect-info")
def connect_info(request: Request):
"""Who am I + the base URL/config an agent should use. Works with either a
cookie session (browser) or a per-user bearer token (agent verifying itself)."""
u = getattr(request.state, "user", None) or _user(request)
if not u:
raise HTTPException(status_code=401, detail={"error": "authentication required"})
base = os.environ.get("ENGRAPHIS_DASHBOARD_URL", "").strip().rstrip("/")
if not base:
base = str(request.base_url).rstrip("/")
return {
"user": {"id": u["id"], "email": u["email"], "name": u.get("name", ""),
"role": u["role"]},
"api_base": base + "/api",
"dashboard_url": base,
# A ready-to-paste snippet for an HTTP-capable agent/MCP-shell:
"snippet": (
f"ENGRAPHIS_API_URL={base}/api\n"
f"Authorization: Bearer <your-token>\n"
f"POST {base}/api/remember {{\"content\": \"...\", \"workspace\": \"default\"}}\n"
f"GET {base}/api/recall?q=...&workspace=default"),
"mcp_over_http": False, # /mcp mount is a follow-up; agents use HTTP today.
}

app.include_router(router)
return True, store
Loading