Publish HTML pages to a stele server and get a readable three-word URL back.
The point of this tool is custody, not ergonomics. Every writer used to share one bearer
token, and an agent that publishes pages had to hold it — a secret in the same context window as
a document about to go up at a guessable URL. Here the token lives in a 0600 file that
stele auth login writes once, from a TTY. The agent runs stele publish page.html and never
sees it.
$ stele auth login
host (e.g. https://stele.example.com): https://stele.example.com
sign in to https://stele.example.com with GitHub.
open https://github.com/login/device
enter the code WDJB-MJHT
waiting for GitHub — this window can be left open. Ctrl-C to stop.
authenticated as projedi1234 on https://stele.example.com — scopes: publish
stored 0600 in ~/.config/stele/credentials.json
no GitHub token ever reached this machine.
$ stele publish report.html
https://stele.example.com/quiet-cedar-otter
expires 2026-08-11 10:31
$ stele publish report.html --slug q3-report --ttl never
https://stele.example.com/q3-report
kept until deleted
$ stele attach chart.png --ttl never
https://stele.example.com/static/plain-amber-heron
kept until deleted
page about it: https://stele.example.com/plain-amber-heron
$ stele update q3-report report.html
https://stele.example.com/q3-report
kept until deleted
$ stele amend q3-report --slug q3-final
https://stele.example.com/q3-final
kept until deleted
$ stele auth status
host https://stele.example.com
client claude-code
scopes publish
expires never
last used 2026-08-04 10:31
state active
credential ~/.config/stele/credentials.json
Only the URL, the JSON and the skill document go to stdout. Prompts, warnings and errors go to
stderr, so url=$(stele publish page.html) captures a URL and stele skill | less pages a
document. The deadline under the URL is on stderr for that reason, and it is printed at all
because the server's default is ephemeral — a page published with no --ttl is eventually
unpublished, and a caller handed only a URL would find that out when the link broke.
stele attach is the one command whose stdout is not the URL the server answered with. An
attachment has two: the bytes, under /static/, and a viewer page about the file. The bytes are
what a page embeds and the viewer is what you send a person, and mixing them up fails silently —
an <img> pointed at the viewer renders nothing, and both answer 200. So the bytes go to
stdout, where src=$(stele attach chart.png) captures them, and the viewer goes to stderr
beneath the deadline. --json names both and calls neither of them url.
| Command | Who runs it | Does |
|---|---|---|
stele auth login [--host <url>] [--admin] |
human, once | Signs in with GitHub — prints a code and a URL to open, waits, writes the credential it is minted 0600. Falls back to a token read from a TTY where the deployment has no GitHub sign-in; an operator token is spent, not stored — see below. |
stele auth status |
agent or human | Host, client name, scopes, expiry, and the GitHub login behind the credential when there is one — never the token. |
stele auth logout |
human | Forgets the local credential. Does not revoke it. |
stele publish <file> [--slug <name>] [--ttl <days>] |
agent | POST /pages. Prints the URL on stdout and the page's deadline on stderr. |
stele attach <file> [--slug <name>] [--ttl <days>] [--filename <name>] |
agent | POST /pages with a binary type. Publishes an image, video or PDF. Prints the URL of the bytes on stdout — the one that goes in an <img src> — and the viewer's under the deadline on stderr. |
stele update <slug> <file> |
agent | PUT /pages/:slug. Never creates, and never changes the deadline. |
stele amend <slug> [--slug <name>] [--ttl <days>] |
agent | PATCH /pages/:slug. Renames a page, moves its deadline, or both, without touching a byte of it. A rename is a hard move — the old URL starts 404ing at once. |
stele delete <slug> |
agent | DELETE /pages/:slug. Permanent and immediate — the slug is freed and returns to the pool, so every link to it breaks. Prints nothing on stdout; the confirmation is on stderr. |
stele skill |
agent | Proxies GET /skill, so the binary keeps zero copies of the contract. |
stele admin clients create <name> |
operator | Mints a credential and prints the token once. --scopes, --expires-in 90d. |
stele admin clients list |
operator | Names, scopes, last use, revocation state, and the GitHub login a credential was signed in for when there is one. |
stele admin clients revoke <name> |
operator | Stops a credential working, keeping its record. |
Every command takes --host and --json. Styling is a presentation layer only; the core
operations in SteleKit return plain data and print nothing, which is what makes --json a
rendering choice rather than a second code path — and JSON output is never styled, because it is
a machine contract.
stele auth login signs in with GitHub. The server starts a device flow; this prints a short
code and a URL, and a person opens that URL wherever they like. Nothing is launched for them,
deliberately — a CLI that shells out to a browser opens nothing over SSH, nothing in a
container, and on a shared machine it opens a page on somebody else's display. What comes back
is a publish-only credential the server minted, verified and written 0600:
$ stele auth login
sign in to https://stele.example.com with GitHub.
open https://github.com/login/device
enter the code WDJB-MJHT
waiting for GitHub — this window can be left open. Ctrl-C to stop.
authenticated as projedi1234 on https://stele.example.com — scopes: publish
stored 0600 in ~/.config/stele/credentials.json
no GitHub token ever reached this machine.
The flow is proxied end to end, and that is the whole point of it. The OAuth app's client ID lives on the server, and the GitHub access token is born and dies inside one server request — this machine holds neither. A login that pastes a GitHub token would have removed a stele secret from the agent's reach by putting a GitHub one on the same disk, which is a worse credential to lose.
Signing in again is the recovery for a lost or forgotten credential. The server retires whatever live credential holds that login's name and mints a replacement, which is the only recovery there can be: it keeps a SHA-256 and cannot reissue a token it never stored.
Two things fall back to a token typed at the terminal. --admin skips the sign-in outright,
because what a sign-in mints carries publish and only publish — it can never produce the
operator credential that flag asks for. And a deployment that has not configured GitHub sign-in
refuses the start route, which is also how the very first credential on a fresh deployment gets
made: the bootstrap token is in the server's environment and nobody has signed in yet.
An admin token is not a credential to keep. It is the one thing that can mint credentials, and
on this server it cannot even publish — admin and publish are disjoint, so a machine holding
the operator token can revoke every credential on the deployment and cannot upload a page.
So on that path stele auth login spends it. Paste an operator token and it mints a
publish-only credential named after the machine, stores that, and never writes what you pasted
to disk:
$ stele auth login
host (e.g. https://stele.example.com): https://stele.example.com
token for https://stele.example.com:
that is https://stele.example.com's bootstrap token (shared-upload-token). It is configuration
rather than a credential — it stops working when the deployment is redeployed — and it carries
`admin`, so storing it here would let anything on this machine mint and revoke credentials.
Minting a publish-only credential for this machine instead. Pass --admin to store the operator
credential as it stands.
name for this machine [argos]:
authenticated as argos on https://stele.example.com — scopes: publish
stored 0600 in ~/.config/stele/credentials.json
the admin token was not written to disk.
A publish-only token takes the path it always took: verified, stored, nothing else said.
If the name is already live the credential is not silently replaced — rotating one is revoke-then-mint under the same name, which destroys whatever is publishing under it today, so it asks:
name for this machine [argos]:
a credential named argos is already live on https://stele.example.com (created 2026-06-02, last
used 2026-08-04).
revoke it and mint a replacement? [y/N]
Declining asks for another name instead.
--admin stores the operator token as it stands, which is what the workstation you administer
the deployment from wants. The credential file holds one credential per host, so that machine
holds admin instead of publish and stele publish stops working on it.
stele admin clients create is unchanged and still the way to provision a machine you are not
sitting at, or to mint a credential with a non-default scope or lifetime. What changed is that
it is no longer the only way — and the common case, a machine credentialling itself, no longer
routes a shown-once secret through a second terminal.
One trade the pasted path makes deliberately: the operator token gets typed at a TTY on the
machine being provisioned, rather than never leaving the workstation. It is read with echo off, used for
one request and never written down — but it is a real exposure, and admin clients create
remains available for anyone who would rather carry the minted token instead.
Pages are ephemeral by default. --ttl says otherwise:
--ttl |
Means |
|---|---|
| omitted | on publish, the server's default lifetime, a matter of days; on amend, the deadline already in force |
30, 30d |
thirty days |
2w |
fourteen days |
never |
kept until you delete it |
Two asymmetries shape the rules here. The first is why the default is ephemeral at all: a page
that outlives its purpose fails quietly and forever, while one that expires too early fails
loudly to somebody who can republish it. The second is why nothing is ever rounded — a lifetime
finer than a day, like 12h, is refused rather than quietly turned into 1d, because the
failure mode of guessing is a link that dies on a schedule nobody typed.
The maximum is deliberately not enforced here. That bound belongs to the server's
PageLifetime, the same way the slug rules belong to its Slug; a copy in this repository
would be a second source of truth that drifts silently the day the server moves it, and the
400 it earns names the real limit. What this side checks is only what the server cannot: that
you wrote something meaning a number of days, and that turning it into one does not overflow.
A deadline is fixed for a page's body, not for the page. stele update still has no --ttl —
replacing a body cannot buy the link another week, and a lifetime quietly reset by every edit
would be a deadline nobody could predict — and the server still answers ?ttl= on PUT with a
400 rather than a 200 that silently ignored it. That refusal is not a gap waiting to be
filled; it is the rule that a replacement cannot retime a page. Moving a deadline is a separate
act, and stele amend --ttl is the one thing that performs it.
Two things about it want knowing before you type it. An omitted --ttl on amend means leave
the deadline exactly where it is — not "apply the default", which is what omitting it on
publish means. The two commands read the same absence in opposite directions deliberately, and
the direction here is the safe one: the alternative is a --slug rename that also, silently,
puts a week's deadline on a page somebody published to keep. And the lifetime you do pass is
counted from the moment of the request rather than from publication, so --ttl 30 on a page
published three weeks ago grants thirty fresh days rather than the nine that were left of them.
It is a new lease, not an adjustment to the old one.
The deadline itself is always read off the response, never computed here: the server resolves it
against its clock at the moment of the upload, applies its own default when you say nothing,
and on update reports the deadline the page already had — a date this side never knew. amend
is no different: what comes back is the deadline now in force, whether you moved it or left it
alone, and it is the only honest answer to what happened, because only the server's clock knows
when "thirty days from now" lands.
A lifetime is one way a link you handed somebody stops working. stele amend --slug is the
other, and it is the faster one, so it gets said plainly: a rename is a hard move. The old name
is freed the instant the rename commits. There is no redirect, no tombstone, nothing recording
that the page was ever called that — the old URL begins serving the ordinary 404, the same
bytes a slug nobody has ever used gets, and the name goes straight back in the pool for the next
--slug anybody asks for. A rename is not a forwarding address. It is a deletion and a
publication that happen to preserve the contents.
Which makes the question to ask beforehand not "would a better name be nice" but "who already
has the old one". A URL that has not left your terminal renames freely, and that is the case
amend is for: the generated three-word slug you have decided to replace before sending it
anywhere. A URL already sitting in somebody's inbox is a different matter — renaming it breaks
it for them, with no notice and no trail back. The tool for changing what a circulated link says
is stele update, which replaces the contents and leaves the address alone. That is the whole
division of labour between the two: update keeps the URL and replaces the page, amend keeps
the page and replaces the URL.
Everything an amendment is not asked to change survives it, including the record of which credential published the page. That is deliberate rather than an oversight: the column names who wrote the bytes, and an amendment writes none — so renaming somebody's page cannot make it look like yours.
The primary reader of this tool is an agent, and an agent branches on $? before it reads
prose. So outcomes with different next steps get different codes; stele --help prints the same
table.
| 0 | success |
| 1 | failed — read the message, fix the input, do not retry |
| 2 | no usable credential here — ask the user to run stele auth login |
| 3 | the server rejected the credential — ask the user to log in again |
| 4 | valid credential, insufficient scope — an operator has to run this |
| 5 | that slug is taken — ask for a different --slug |
| 6 | the file is too large or a type the server will not store |
| 7 | no such page or client |
| 8 | the CLI is too old — reinstall it and retry once |
| 9 | could not reach the server — retryable |
| 10 | the server failed — retry once, then stop |
$ stele publish report.html --slug q3-report
Error: that slug is already taken: slug 'q3-report' is in use. Choose another `--slug`, or omit
it and let the server generate one.
$ echo $?
5
The message carries the remedy the route has, which is why the table above does not. Dropping
--slug is an escape publish offers and amend does not: there it means "do not rename", the
server allocates nothing, and an amendment asking for neither a name nor a deadline is refused
before it is sent. A 5 from stele amend means pick a different name or leave the page where it
is.
Code 8 is the version gate: the CLI sends User-Agent: stele-cli/<version> on every request and
a server whose minimumCLIVersion is higher answers 426. The remedy is printed with the
error — just -f ~/repos/stele-cli/justfile install, then retry once.
No environment variable configures the credential, the token or the host. Not one, and there
is no STELE_TOKEN escape hatch for CI either: an env-var fallback would reopen the exact hole
this tool closes, and it would get used, because it is easier. The credential file is the only
configuration this program has, and whatever an env var would have answered, it answers instead.
The claim is that precise because it is the checkable one. The program does read the
environment, in two places that could not carry a secret if they tried: NO_COLOR, TERM and
COLORTERM decide whether output is styled, and SAP_SHELL tells the completion generator which
shell asked. Neither names a host, and neither can supply a token. "There are no environment
variables" would be a tidier sentence and a false one, and a security claim that is false in a
detail is one a reader is right to stop trusting in general.
HOME is not one of them, which is worth stating because it is the natural guess. The
credential file's directory comes from NSHomeDirectory(), and on Linux — the platform the
agents run on — that resolves through the passwd database (getpwuid(getuid())->pw_dir) rather
than reading $HOME. Checked, not assumed: HOME=/tmp/elsewhere stele auth status --json still
reports the real user's path in credentialFile. So there is no environment variable anywhere
that relocates the credential — a stronger property than the one a reader would assume, with one
practical consequence worth knowing before it surprises you: a script cannot sandbox this program
into a temp home. Anything needing to run against a scratch credential has to move the real file
aside and put it back, which is exactly what scripts/integration-smoke.sh does.
~/.config/stele/credentials.json, keyed by host so one file can hold several deployments:
{
"default": "https://stele.example.com",
"https://stele.example.com": { "client": "claude-code", "token": "stele_pat_…" }
}Host selection is the case that matters. stele auth login --host <url> records the host it
authenticated against; commands use it implicitly when the file holds exactly one, the default
key breaks the tie when it holds several, and --host overrides per invocation. Nothing is
guessed: several hosts with no default is an error naming them, because a stele publish whose
destination depends on something invisible is worse than one that stops and asks.
There is deliberately no XDG_CONFIG_HOME support, and — per the note above — no HOME support
either. A variable that relocates the credential file is a way to point an agent at a
credential the user did not write, and it turns "where is my credential?" into a question with an
invisible answer. The file is where the user's account says it is, and nothing in the environment
moves it.
Four rules make the custody boundary real:
-
The token is never an argument.
auth loginreads it from a TTY, because argv is visible inpsand lands in shell history — and shell history is something an agent reads. There is no--tokenflag to reach for, and a non-TTY stdin is refused rather than read:echo $TOKEN | stele auth loginwould put the credential straight back into the environment and the history this design removes it from. -
Loose permissions are refused. A group- or world-readable credential file is an error, the way
sshtreats a private key, not a warning to proceed past — and the check runs on every load, not only at login, because achmodafterwards is exactly the accident it is here for. -
The token only ever goes to the host it was filed under. Not just in how the URL is built:
URLSessionfollows redirects by itself and copies theAuthorizationheader onto the redirected request, so a server that can answer for the configured host could collect the credential by replying302 Location: http://somewhere-else/while the caller saw an ordinary success. The transport installs a redirect policy that follows a 3xx only within the same scheme, host and port, and reports anything else as a refusal instead of following it. -
No subcommand ever prints the token, including
auth status, including--json, and including error paths. A 401 says the credential was rejected, not which credential. This is enforced by access control rather than by care:Token's plaintext accessors areinternaltoSteleKit, so the executable — which is where everyprintlives — has no expression that yields it.The one exception is
admin clients create, which must print the token it just minted because the server keeps only a SHA-256 and cannot reissue it. That path goes throughMintedToken, a separate type whose.secretis the library's only public accessor, spelled out at the call site so it is the line a reviewer stops on. It is in the--jsonpayload too — omitting it there would make--jsona quiet way to lose a credential you just created.
This stops accidental exposure — echoed commands, transcripts, a token pasted into a page —
which is where essentially every real leak comes from. It does not stop an agent that decides to
cat the file, and it is not sold as doing so.
Same three commands the server's skill document gives an agent that finds stele missing:
git clone git@github.com:ProJedi1234/stele-cli.git ~/repos/stele-cli
just -f ~/repos/stele-cli/justfile install # builds release, installs to ~/.local/bin
just -f ~/repos/stele-cli/justfile install-completions # optional, zsh onlyRequires Swift 6.0+ and just. Builds and runs on Linux and macOS.
PREFIX defaults to ~/.local, so PREFIX=/usr/local just install puts it elsewhere. The install writes to a temporary name and
renames over the target, because writing over a binary that is currently executing fails with
ETXTBSY and rename(2) does not — a second agent mid-publish must not be able to fail your
reinstall.
By hand, if you would rather not use just:
swift build -c release
install -m 755 .build/release/stele ~/.local/bin/steleTwo traps worth naming, because neither error message points at its cause:
~/.local/binhas to be onPATH, or the freshly installed binary is invisible andstele auth statusreports "command not found" on a machine that has it.- On a swiftly-managed toolchain,
swift buildin a non-interactive shell needsLD_LIBRARY_PATH=~/.local/share/swiftly/compat-lib. The.zshrcsets it, but a process shelling out non-interactively does not inherit that, and the build fails with a linker error that looks nothing like a missing environment variable.
just install-completions # writes _stele to oh-my-zsh's custom/completions, or ~/.zfunc~/.zfunc needs fpath=(~/.zfunc $fpath) in .zshrc before compinit; oh-my-zsh's
custom/completions is already on fpath and needs no edit. just install-completions removes
~/.zcompdump* afterwards, which is what makes the new completions appear without a manual cache
purge. stele --generate-completion-script {zsh,bash,fish} emits the script directly if you
install it elsewhere.
Stored hosts are completed by calling the installed binary back at TAB time, so --host <TAB>
offers the deployments this machine actually holds a credential for. Only the command tree is
baked into the generated script, so it needs regenerating when commands or flags change — not
when your hosts or credentials do.
swift testEverything with a decision in it lives in SteleKit as a pure function taking its world as a
parameter — the home directory into CredentialStore, the transport into SteleClient — so the
suite covers the credential file's permissions and host resolution, the request each command
builds, the status-code vocabulary, and the promise the whole project rests on: that no
rendering of a credential, and no case of any error type, can be made to print a token.
The one exception is the redirect policy, which is a decision URLSession makes rather than one
this code makes — a fake transport would test the seam and not the thing. So those tests stand up
two real HTTP servers on loopback ports and assert that the one the credential was not filed
under never sees a byte of it, while a redirect within a single origin is still followed.
swift test cannot catch a disagreement with the server, and this is not a hypothetical: every
assertion in it runs against a FakeTransport whose expectations were written from this
repository's code. A field name spelled differently from the server's is therefore wrong in the
client and wrong in the test that checks the client, and both halves agree with each other all
the way to the first live request. That is how four contract breaks once shipped with two green
suites — including a --expires-in that travelled under a key the server ignored, earning a
cheerful 201 and a credential that never expired.
The fix is the one thing no unit test can do: drive the real binary against a real server.
scripts/integration-smoke.sh --host http://127.0.0.1:8099 --token "$TEST_TOKEN"It boots nothing — point it at a server that is already running and give it that deployment's
STELE_UPLOAD_TOKEN, which is the documented bootstrap credential and the only thing that can
mint the first client. Arguments may also arrive as STELE_SMOKE_HOST, STELE_SMOKE_TOKEN,
STELE_BIN and STELE_SMOKE_PSQL.
| Flag | ||
|---|---|---|
--host <url> |
required | The server under test. |
--token <value> |
required | Its STELE_UPLOAD_TOKEN. A test-run token, never a real one. |
--bin <path> |
optional | The binary to drive; defaults to stele on PATH. Use .build/release/stele to test what you just built rather than what is installed. |
--psql <command> |
optional | A command that reaches the server's database, e.g. docker exec stele-postgres psql -U stele -d stele_smoke. Only the attribution check needs it, because no route reports who wrote a page — without it that one check prints skip. |
It walks the whole documented lifecycle — mint, log in, auth status, publish, fetch the bytes
back and compare them, attach, update, amend, --expires-in there and back, expiry enforced,
a publish-only credential answered 200 by whoami and 403 by the admin routes, revocation that
really stops working, the exit-code vocabulary, the version gate, and the byte-identity of every
404. It prints each check, stops at the first failure, and exits non-zero.
The amend leg is the one worth naming, because most of what it asserts cannot be seen from
inside this repository: that a rename really does free the old name at once — the old URL 404s
in the same breath, and the name is claimable again — that the contents and the content type
come through the move untouched, and above all that a rename with no --ttl leaves the deadline
exactly where it was. That last check is run against a page published --ttl never, so a client
that helpfully sent a default lifetime would show up as a permanent page with a week to live.
It also pins the refusals: exit 5 for a name another live page holds, exit 7 for a slug with no
live page behind it, and exit 1 for an amendment that named nothing to amend — that one with the
credential file moved aside, which is how the script proves the refusal happened before anything
was sent.
Two things it does not do, stated because a canary you trust wrongly is worse than none:
- It cannot drive
stele auth logininteractively. That command demands a TTY by design and refuses a pipe; the script asserts the refusal — which is the load-bearing custody rule — and then seeds the credential file at0600in the documented format for the rest of the run. The prompt, the echo-off read and the verify-before-write are not covered. - It leaves litter. Each run leaves ten pages and three credential rows behind. The pages it
deletes it deletes on purpose, to prove the verb works; the rest are the evidence the later
checks read, so they outlive the run. Credentials cannot be cleaned up at all — it revokes
every one it mints, but a revoked row is still a row. Seven of those pages expire on their own
— the lifetime checks publish several deliberately — but three were published or amended to
--ttl neverand will still be there next year. The footer names those three, since they are the only ones a human ever has to clean up. Run it against a throwaway deployment, never against production.
It also moves ~/.config/stele/credentials.json aside and restores it on every exit path,
including a failed check — see the HOME note under Configuration for why it cannot simply use a
temp directory instead.