@gsjmedia-os/gsjos
v0.5.1
Published
Headless access to a gsjmedia OS workspace — CLI and MCP server over one API core.
Readme
gsjos — the OS client
Headless access to a gsjmedia OS workspace, with two faces over one core:
gsjos people.list --q "acme" # CLI — humans, scripts, cron, CI, agents that shell out
gsjos pipeline.move <id> proposal_sent
gsjos mcp # MCP server (stdio) — Claude Code, Codex, Cursor, OpenClawThis is Layer 1 of architecture/11-agent-runtime-and-fleet-plan.md:
the one door through which any agent reads context and takes actions. Runtimes are
replaceable; this is not.
Why it is shaped like this
One capability set, two syntaxes. src/verbs.mjs defines every verb once —
name, arguments, route, human rendering. The CLI parses flags into it; the MCP server generates
JSON Schema from it. Adding a verb there adds a CLI command and an MCP tool simultaneously, with
identical semantics. Neither face can drift from the other because neither has its own logic.
Everything goes through /api/v1, never Postgres directly. So an agent gets the same
guardrails a human gets: legal status transitions enforced, the same triggers fired, the same
row-level security applied. An agent can be wrong in judgment; it cannot be wrong in data
integrity.
Zero dependencies. An agent host can npx @gsjmedia-os/gsjos mcp with nothing else installed,
and there is no SDK version to drift. The MCP stdio transport is ~120 lines of JSON-RPC.
Setup
npm i -g @gsjmedia-os/gsjos
gsjos login # asks for API URL, workspace slug, and an API keylogin prompts only for what you did not pass, so it also runs where nothing can answer a
prompt — a provisioning script, or an agent session with no TTY:
gsjos login --url https://os.gsjmedia.co --workspace gsjmedia --key gsjos_live_…
op read op://vault/gsjos/key | gsjos login --url … --workspace … --key - # keeps it out of history
GSJOS_API_KEY=gsjos_live_… gsjos login --url … --workspace …The key is read from --key, then GSJOS_API_KEY, then a prompt. With no TTY and neither of
the first two it fails naming them, rather than hanging on a question nothing will answer.
Node 20+ for the CLI and the MCP server. Node 22+ for gsjos daemon, which needs a
global WebSocket to reach the local agent runtime — it fails at startup with that message
rather than midway through a run.
Create the key in the OS: Settings → Agent runtime → API keys. It is shown once.
A key is not a password and not a service-role key: the server binds it to a workspace agent
identity that sits in workspace_members with role='agent', so requests made with it are held
to exactly the same row-level permissions as a person's. A key cannot reach another workspace.
Credentials resolve from the environment first, then ~/.gsjos/config.json — so a laptop uses
gsjos login and a provisioned droplet gets them injected:
GSJOS_API_URL=https://app.example.com
GSJOS_API_KEY=gsjos_live_…
GSJOS_WORKSPACE=gsjmediaAs an MCP server
Any MCP client, e.g. Claude Code:
claude mcp add gsjos -- npx -y @gsjmedia-os/gsjos mcpOr by config, for a client that reads one:
{
"mcpServers": {
"gsjos": {
"command": "npx",
"args": ["-y", "@gsjmedia-os/gsjos", "mcp"],
"env": {
"GSJOS_API_URL": "https://app.example.com",
"GSJOS_API_KEY": "gsjos_live_…",
"GSJOS_WORKSPACE": "gsjmedia"
}
}
}
}The server's initialize response tells the agent to call schema first. That verb returns the
workspace's status vocabularies, legal pipeline stages, and writable fields — generated from the
same modules the UI and the database constraints use, so it cannot go stale.
Verbs
Run gsjos help for the list, gsjos help <verb> for one in detail.
| | |
|---|---|
| schema | status vocabularies, legal transitions, writable fields |
| people.list · people.get · people.update | the prospect warehouse |
| companies.list | firmographics |
| pipeline.list · pipeline.move | deals by stage |
| search | people + companies by name |
| interactions.log | log a touch — inbound promotes to a lead via a DB trigger |
| tam.list · tam.create · icp.list · icp.create | targeting |
| runs.list · runs.enqueue · agents.dispatch | the agent work queue |
| webhooks.list · webhooks.create · webhooks.delete | subscribe a URL to workspace events |
Add --json to any verb for raw output instead of the human rendering.
Walking a large workspace
List verbs are paginated. --limit sets the page size and --cursor takes you to the next
page, but you rarely need either — --all walks every page for you:
gsjos people.list --all --json > people.jsonThe pagination is keyset, not offset, so a walk of 50k people will not skip or repeat a row
even while the workspace is being written to underneath it. That is what makes --all safe
to point at an export.
List values, and commas that are data
A list argument splits on commas. Plenty of real values contain one — the LinkedIn industry
Technology, Information & Internet is a targeting profile away from becoming two industries
nobody chose. Escape it, or repeat the flag:
gsjos tam.create --name "Agencies" --industries 'Marketing Services,Technology\, Information & Internet'
gsjos tam.create --name "Agencies" --industries "Marketing Services" --industries "Technology, Information & Internet"Both produce two entries, with the comma intact in the second. The MCP face takes a real array, so this is a shell problem only.
Re-running a write
The API replays a POST that carries an Idempotency-Key it has already seen, for 24h, rather
than running the handler again. --idempotency-key is how you send one:
gsjos tam.create --name "Agencies" --idempotency-key tam-agencies-v1Run that script twice and you get one TAM and the same response, not two TAMs. Key it per logical operation — not per retry — and a re-run of the whole script is safe. Reusing a key with a different body is a 409, deliberately: that is a caller bug and hiding it would make it permanent.
Permissions
A key can be narrowed to individual verbs. If a call comes back with
this API key is missing the 'pipeline.move' scopethe key was issued for an agent whose agent.md does not declare that verb. Widen the grant
in the agent definition and re-issue the key — do not reach for a broader key.
Tests
node test/smoke.mjsDrives the MCP server over a real stdio pipe against a stub API: protocol handshake, tool
listing, tool calls, error shapes, plus CLI argument parsing and auth headers. It asserts on
the request body the stub receives, not just the exit code — an argument that parsed into the
wrong shape is invisible from a 200. It covers every non-interactive login path, walks
a paginated stub end to end, and checks every verb here against the server's own scope
catalog in app/src/lib/api/verbs.ts — a verb the CLI can call but the server cannot scope
would be a hole in the permission model, so the two lists are not allowed to drift.
