Oberik over SSH
ssh ssh.oberik.com
Everything the dashboard does, as text: sign up, create a project, set the capability
ceiling, upload documents, mint tokens, publish tools, read the docs, talk to the agent.
No client to install and nothing to configure — if you have ssh, you have Oberik.
The command set is not hand-written. The control plane describes its own routes, and the gateway generates commands, help and forms from that description at connect time — so a new dashboard feature appears here without anyone updating a CLI.
For coding agents
This interface exists mostly for you. A browser-based dashboard is unusable to something that only has a shell, and the alternative — asking your user to paste an API key into your terminal — is the habit that makes credential theft work.
Sign in without ever handling a credential:
ssh ssh.oberik.com 'login link'
# {"ok":true,"data":{"url":"https://oberik.com/auth/device?code=7E2R2NZY","code":"7E2R2NZY"}}
Show that URL to your user and ask them to open it. Then:
ssh ssh.oberik.com 'login wait' # blocks until they approve, then returns
The two are separate commands on purpose. login link returns immediately, because
a command that blocked while waiting would hand you its link only after the request had
expired — you would never get to show anyone anything. login wait is the part that
blocks, which is fine, because by then you know what you are waiting for.
What your user sees is a page naming the SSH key being added, with approve and decline. You never see their password, and they can check the code on the page against the one you printed. Once approved, the key is registered: every later connection is signed in automatically, with no link at all.
If you already have a key registered, none of this is needed — just run commands.
Read the documentation without leaving the shell:
ssh ssh.oberik.com 'docs' # every page, with what it covers
ssh ssh.oberik.com 'docs page quickstart' # one page, in full
ssh ssh.oberik.com 'docs search capability' # the lines mentioning something
This is the same text as the docs site, so nothing is a summary of something else.
Everything is scriptable:
ssh ssh.oberik.com 'discover' # the whole command catalog as JSON
ssh ssh.oberik.com 'projects --json' # any command, as JSON
ssh ssh.oberik.com 'format json; project use acme; capabilities' # several per line
The flag goes inside the quotes. ssh ssh.oberik.com --json 'projects' cannot run:
ssh parses its own options after the destination too, so the client eats --json and
answers with its own usage dump — which mentions neither Oberik nor the flag, and reads
like a broken host or a bad key. Everything after the destination is one command line for
the gateway, so every flag belongs inside it.
format json is the better answer for a line with several commands: it sets the output
format for the rest of the line rather than being repeated per command.
If a VALUE starts with --, put -- in front of it — everything after that is an
argument rather than a flag:
ssh ssh.oberik.com 'docs search -- --json' # search for the text "--json"
discover is worth reading first: it gives every command, its parameters and their
types, so you can drive this without guessing at syntax.
The JSON envelope. Every response is one line:
{"ok": true, "command": "projects", "message": null, "data": [ … ]}
{"ok": false, "command": "playground end", "message": "…", "error": "…", "data": null,
"status": 400}
Branch on ok. The human-readable text is in message either way — a failure repeats it
in error, which is the field to prefer when you want only the failure text. It used to
appear in error alone, so a client logging message printed a blank for a perfectly
clear three-line error. status is the HTTP status behind it, so a retry can tell a 429
from a 400.
Every line gets one, including the ones that failed, and including a line that was never
a command at all. That holds in a session you hold open as well as in a one-shot
invocation — the two used to differ, so a program running several commands down one
connection parsed the successes and got human prose for the failures. If you asked for
JSON, prompts are off too: a command missing a required field answers with what is missing
rather than opening a form into the middle of your stream, and a destructive one answers
re-run with --yes rather than stopping to ask.
A first session, end to end
# 1. sign in (or `signup <email> <password>` if you have no account)
ssh ssh.oberik.com 'login link' # show the URL to your user
ssh ssh.oberik.com 'login wait' # blocks until approved
# 2. an organization and a project — the project provisions an isolated tenant
ssh ssh.oberik.com 'org new "Acme"'
ssh ssh.oberik.com 'project new "Support Bot"'
# 3. what tokens minted for it may do
ssh ssh.oberik.com 'project use "Support Bot"; capabilities set --allowTodo on --allowComputer on'
# 4. a server-side key, then an end-user token
ssh ssh.oberik.com 'key new ci' # pk_… shown once
ssh ssh.oberik.com 'token --subject acme:user_1' # a JWT for one end-user
# 5. try the agent
ssh ssh.oberik.com 'chat "what can you do?"'
Every one of those maps to a dashboard screen; open browses the same thing as pages
with numbered menus if you would rather look around than know the command.
Two identities on one connection
Almost every command acts as you, the operator — documents, document upload,
capabilities, providers, sources, audit. One does not:
ssh ssh.oberik.com 'dp GET /capabilities' # {"subject":"user", …}
dp is the raw data-plane escape hatch, and the data plane only speaks end-user tokens, so
it mints one — it sees the project the way one of your customers does. That makes
documents and dp GET /documents return different corpora on the same connection:
ssh ssh.oberik.com 'document upload notes.txt --visibility self'
ssh ssh.oberik.com 'documents' # you see it — you own it
ssh ssh.oberik.com 'dp GET /documents' # a customer does not, so neither does dp
Nothing is broken there: a self-visibility document belongs to whoever uploaded it, and
the ACL is doing its job in both answers. But the difference is silent — a shorter list
and a 200, never a refusal — so use documents and document to audit what a project
holds, and dp to check what a customer actually gets.
The corollary is that dp cannot do the operator-only things either. /audit requires an
admin role and an end-user token is not an admin, so dp GET /audit answers
403 admin role required — read the trail with audit, which goes through the control
plane with the project key like every other operator command:
ssh ssh.oberik.com 'audit --limit 20'
ssh ssh.oberik.com 'audit --subject acme:user_1'
Reporting a bug
ssh ssh.oberik.com 'bug report "Uploads fail" "A 14MB PDF returns a 500."'
Two arguments, because everything else is already known: who you are comes from the
session, and which client filed it from the connection. Add --project when it is about
one — by name, the way you would say it:
ssh ssh.oberik.com 'bug report "Retrieval is empty" "No chunks come back." --project "Support Bot"'
A project you cannot reach is refused rather than dropped, so a report never ends up filed against nothing when you thought you had said where it happened.
bugs lists what you have filed and bug status <id> closed closes one when it stops
happening. The same reports arrive from the dashboard's Report a bug, in the sidebar.
For anything that is not a bug — a feature you need, a question about your integration, or a look at what you are building — hello@oberik.com reaches a person.
Registering a key by hand
If you already have a session — or you would rather not use the link flow — the key you connected with can be registered directly:
ssh ssh.oberik.com 'ssh-key add' # registers the key this connection presented
ssh ssh.oberik.com 'ssh-keys' # what is registered
ssh ssh.oberik.com 'ssh-key rm <id> --yes' # remove one — confirmed, like the others
ssh-key add with no argument uses the key you are already connected with, so there is
nothing to paste.
A key is identified by its fingerprint, not by its name. ssh-key add derives the name
from the enrolment command, so every key enrolled the same way carries the same one — the
picker and the confirmation both name the fingerprint instead.
status says which key this connection is using — it is the keyFingerprint field,
and it is null on a password or guest session because there is no key to name. So you do
not have to work it out from lastUsedAt; and if you point ssh-key rm at that one, the
confirmation says so:
! Remove a registered SSH public key (SHA256:kBliaV…) — this is the key this connection
is using, and you cannot re-add it over SSH — irreversible. Re-run with --yes to confirm.
Sending a file
There is no SFTP and no file picker: the gateway runs on our side and cannot read your
disk. So a command that takes a file takes its name as the argument and its bytes
from the connection — you pipe the file into the ssh command itself.
ssh ssh.oberik.com 'use "Support Bot"; document upload handbook.pdf --tags policies' < handbook.pdf
ssh ssh.oberik.com 'use "Support Bot"; skill upload refunds.zip' < refunds.zip
The filename you pass is the name the document is stored under, so it is worth it matching the file you are piping — nothing checks that they agree.
For something short, or from a session with nothing to pipe, --content takes the body
inline instead:
ssh ssh.oberik.com 'use "Support Bot"; document upload notes.md --content "Refunds are 30 days."'
help document upload lists both, along with every other field the route takes. This works
for any described route with a file field, present or future — discover --json reports
them as "type": "file", which is how a client can tell a field that needs bytes from one
that needs a string. It is the only way a command can read your bytes, so the catalog
cannot be wrong about which ones do.
An upload is stored tenant-visible by default — readable by every end-user of the
project, which is what a curated corpus is for. The same fields the dashboard offers are
here to narrow it:
ssh ssh.oberik.com 'use "Support Bot"; document upload rates.csv \
--visibility groups --acl_roles finance' < rates.csv
Ingestion is asynchronous. The row appears immediately and turns ready once it is
indexed, so poll it if the next thing you do depends on the corpus:
ssh ssh.oberik.com 'use "Support Bot"; documents' # status per document
Destructive commands, and how confirming works without a person
Some commands ask before they run — project rm, org rm, document rm, wiki rm,
ssh-key rm. discover reports which, as confirm: true on the command.
ssh-key rm is the one worth knowing about: every other delete here is re-doable from the
same connection — mint another key, re-add the provider, re-add the MCP server — while this
one removes the credential the connection is authenticated by. Remove the key you are using
and you cannot re-add it over SSH, because you can no longer open a session.
What confirming means depends on who is driving, and the safeguard is deliberately not the prompt:
- At a terminal, you are asked to type
yes, with the target named in the question. - Non-interactively —
ssh host 'project rm …', a script, an agent — there is nobody to ask, so the command refuses outright unless you pass--yes. - In both cases the target has to be named as an argument.
--yesskips the prompt; it never skips the name.project rmtakes the project's name and the server checks it against the selected project, so the delete either lands on the project you identified or on nothing at all:
ssh ssh.oberik.com 'project use "Support Bot"; project rm "Support Bot" --yes'
# and a mistake is a 400, not a deleted workspace:
ssh ssh.oberik.com 'project use "Support Bot"; project rm "Staging" --yes'
# ! "Staging" is not this project's name — it is "Support Bot". Nothing was deleted.
That is the part that matters for an agent. A prompt is a speed bump for someone who is already looking at the right screen; the failure mode for a client that is not looking at a screen is acting on whatever the connection happens to have selected. Naming the target removes that, and the acknowledgement is recorded in the command log either way.
project rm erases the project's tenant — documents, agent knowledge, conversations,
uploaded files, sandboxes, scheduled tasks — along with its keys and provider credentials.
It answers with what the erasure reached. There is no undo.
What it is not
The gateway holds no state and no privileges of its own. Every command runs through a control-plane session over HTTP, exactly as the React app does — so it can do what your account can do, and nothing more. Signing out, revoking a key, or deleting the account takes effect here immediately, because there is nothing else to revoke.
What is recorded
Every command you run over SSH is logged, with the address it came from and — once you are signed in — your account. The gateway is a public front door that anyone may connect to anonymously, so this is how abuse is told apart from use.
Credentials are not stored. A password typed at login, and a provider key passed as
--values {"api_key":"…"}, are replaced with <redacted> before the row is written. The
rest of the command is kept as-is, so login you@example.com <redacted> still records who
tried. Alongside it is a hash of the original line, which lets repeated commands be
correlated without the credential being recoverable.
Records are kept for 30 days, or 100,000 commands, whichever comes first — except for rows an operator has flagged while investigating something.
If you would rather not have a credential in a log at all, don't pass one: register your
key (ssh-key add) or use the browser flow (login link), and no password is typed on a
command line in the first place.
Repeated failed logins get slower. After a handful of wrong passwords for the same
account (or from the same address) inside ten minutes, each further attempt is held back a
little longer, up to a few seconds. Nothing is ever locked: a person who has forgotten
which password they used never reaches the delay, and a client whose credentials have gone
stale is slowed rather than shut out. If you are automating login, the fix is the
credential — a retry loop against a wrong password gets progressively less useful, which is
the point.