An agent that filed tickets on issues.poietic.tech used to be a user made by hand inside the tracker, holding a trk_ API token in local CLI config. That covered one service. The agent had no identity anywhere else on poietic.tech, and nothing tied it to the person it worked for. Deactivate that person and the agent’s token kept working until somebody remembered it.
The shortcut would have been worse: let Claude Code run the tracker’s CLI with my own session. Every ticket it opened would be recorded as mine, and a leaked credential would be all of me, admin role included. The design also rejected a gentler variant, a token whose subject is the owner “acting as” the agent, because attribution would still default to the owner.
What the auth service does instead is make an agent a row in the same users table people are in, with kind = 'agent' and an owner_id. Services already key on a user id, so an agent can be assigned work, join groups and author comments with no new kind of principal. The owner link says who answers for the agent and when it should stop. It grants nothing. An admin’s agent carries role: "user", and its groups are its own memberships rather than its owner’s.
A secret that only auth sees
An agent signs in with an agent token: pat_ and 43 base64url characters, shown once, stored only as a SHA-256 hash, revocable, at most five live per agent. The secret has one destination, POST /token on auth.poietic.tech, which trades it for the same ten-minute identity token a signed-in person carries, plus two claims, kind and owner. Services verify that token against the published keys as they already did for people. They never see the pat_.
The @poietic-tech/auth client, published to npm at the start of this work for user groups, keeps the change small for a tool. From its README:
import { agentFromEnv } from "@poietic-tech/auth";
const agent = agentFromEnv(); // null when POIETIC_AGENT_TOKEN is unset or empty
if (agent) {
await agent.fetch("https://issues.poietic.tech/api/issues", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "Found a bug" }),
});
}
The null case is deliberately narrow. A variable that is set but malformed throws a TypeError that never quotes it, because a damaged secret must not quietly drop a tool back to acting as the person. Services apply the same rule in the other direction: a Bearer header decides on its own, so one that fails to verify makes the visitor nobody even when a valid session cookie came with it.
Ownership as the off switch
Only admins and members of an admin-managed agent-owners group may own agents. If a person loses that standing, every one of their agents stops: /token refuses the tokens with the same 401 as any other failure, and /session/live stops vouching for identity tokens already issued. Adding the person back restores access; revoking the tokens ends it for good.
That endpoint makes revocation quick at a service rather than ten minutes late. The tracker asks it on every identity-token request, reads and live streams included, and caches each answer for 30 seconds, so its docs put a revoked agent’s remaining time at about half a minute. It fails closed. If the issuer doesn’t answer within five seconds, the request gets a 503, since a degraded issuer is exactly when nobody is watching.
On the tracker, the first request from an unknown agent provisions a local agent member owned by the user its owner claim maps to, and is refused if that owner has never signed in, so no orphans appear. Deactivating a person there stops their agents too. Agents get no browser session and none of their owner’s project roles. Anywhere an actor is shown, in the CLI or the web app, an agent now reads like the PR’s example, “Ada bot · agent · owned by @ada”.
The same model carried agents that create agents. A person can mark an agent a builder, which may then create agents through an /agents API, but ownership stays flat: whatever it creates belongs to the person, counts against a per-owner quota (ten unless an admin changes it), and is deleted with the builder. That API takes the pat_ rather than an identity token, because every service an agent talks to holds its identity token and could replay it there.
Where the boundary moved
Agent tokens still meant pasting a secret into a harness’s environment. The last piece replaced that with a device-flow login. The terminal shows a code; the person types it at auth.poietic.tech and approves; the terminal gets a command-line session listed on the account page beside browser sessions, with a 30-day idle timeout and a 90-day hard limit. A tool can then act as one of the person’s agents without its secret by sending X-Poietic-Act-As with the agent’s name at the exchange. The token that comes back names the agent, so the ticket is still the agent’s. In the 0.4 client, resolveCaller chooses: POIETIC_AGENT_TOKEN if set, else the login acting as POIETIC_IDENTITY, else the person.
The login’s design is plain about the cost and calls itself an audit boundary, not a trust boundary. A harness running as my OS user can read the login file, or leave POIETIC_IDENTITY unset, and act as me; holding only an agent’s pat_, it couldn’t. That risk is written down as accepted, because the point is recording which identity did the work, not sandboxing the harness. Owners can turn acting-as off per agent.
The auth changes went out through the service’s CI deploy on merge, and the tracker’s PRs merged with their end-to-end suites passing. The command-line login is covered by an end-to-end test inside the auth service’s suite, from the device code to revocation on the account page. Its plan also describes a probe against production; the repository doesn’t record one being run, so I’m not claiming it.