Context
kbagent token list answers "what tokens exist" but not "which of them are still in use". There is no last-used signal at any scope today, and the gap is not a CLI omission — the Storage API's token payloads genuinely do not carry one.
kbagent docs query against the Storage + Manage specs confirms the split:
| Response |
Has lastUsed? |
TokenVerifyResponse, TokenListResponse, TokenCreateResponse, TokenRefreshResponse |
no |
ManageTokenVerifyResponse (Manage API / PAT) |
yes — ISO 8601 +HHMM, null if never used |
So for project-scoped Storage tokens the value has to be derived. It is derivable: GET /v2/storage/tokens/{id}/events returns that token's event feed, and its newest entry is an effective last-used timestamp.
This issue asks for that derivation to live behind an opt-in flag on token list, because the raw endpoint has three sharp edges (below) that every caller would otherwise have to rediscover.
Why it matters
Token hygiene on a shared project is currently unauditable. On one project in our fleet, token list returns 25 tokens — a mix of per-user master tokens, MCP tokens, kbagent device tokens and [_internal] Token for triggering … orchestration tokens. Nothing in the output distinguishes a token used four minutes ago from one last used five months ago, so the questions you actually want to answer before revoking anything —
- which tokens are dormant and safe to
token delete?
- which were minted and then never used at all (mis-provisioned, or a onboarding step that silently failed)?
- who is still actively hitting this project, and with what kind of token?
— all require the web UI, one token at a time. #34 established that token identity on a shared project matters; this is the same hygiene problem one step later in the lifecycle.
Deriving it by hand is ~20 lines of Python plus two non-obvious correctness rules, which is exactly the shape of thing that belongs in the CLI rather than in everyone's local scratch script.
Current state (verified on d8f7a7b (v0.86.0), live API, europe-west3.gcp)
No path exists in-tree. rg lastUsed src/ → 0 matches; no client method touches /tokens/{id}/events:
client/tokens.py::list_tokens() (L162) returns GET /v2/storage/tokens verbatim, and its own docstring enumerates the available fields — id, description, created, expires, isExpired, isMasterToken, the can* grants, bucketPermissions, creatorToken. No usage data among them.
commands/token.py L86–91 renders a fixed 6-column table (ID, Description, Created, Expires, Master, Created by) with no column-selection flag.
permissions.py L64: "token.list": "read".
server/routers/token.py L35: GET /{project}/list already exists.
sortOrder is honoured on the events endpoint, though it is undocumented there. The public spec lists only limit, offset, sinceId, maxId, component for /tokens/{id}/events. Testing asc-vs-desc (rather than just checking for a 200) shows it is really applied, and that desc is already the default:
GET /v2/storage/tokens/{id}/events?limit=1 -> 2026-08-20T09:13:33 storage.tablesListed
GET /v2/storage/tokens/{id}/events?limit=1&sortOrder=desc -> 2026-08-20T09:13:33 storage.tablesListed (identical)
GET /v2/storage/tokens/{id}/events?limit=1&sortOrder=asc -> 2026-08-10T15:30:23 storage.tokenCreated
Reads do emit events (storage.tablesListed, storage.tableDetail, storage.bucketsListed, ext.keboola.mcp-server-tool.*), so coverage across token types is good. A full sweep over all 25 tokens produced a usable recency ranking, from 09:54:23 today down to one token with no events at all.
Three sharp edges the CLI should absorb
1. The feed mixes actions by the token with changes to the token. A freshly minted, never-used token's newest event is its own storage.tokenCreated, so a naive events[0].created reports it as "used today". Disambiguate on objectId. Verified both directions on the same event:
| Feed being read |
event |
objectId |
token.id (who acted) |
Meaning |
token 7414128 |
storage.tokenCreated |
7414128 |
7257569 |
about itself — never used |
token 7257569 |
storage.tokenCreated |
7414128 |
7257569 |
action by it — it minted the other token |
Rule: ignore events where objectId == <the token's own id> and event starts with storage.token. Applying it to 7414128 leaves 0 real uses — created, never used — which is the state you most want the audit to surface, and the one a naive read gets exactly backwards.
2. Retention is 6 months (per the token-detail Events tab docs). "Unused for >6 months" and "never used" both come back []. These should not collapse into the same output — see open questions.
3. Events carry uuid, not id. "id" in event is False; the key is uuid (e.g. 01a01e1f-5c53-725a-9fe7-56945f67487a). The documented sinceId/maxId params therefore have nothing in the payload to pair with. Harmless at limit=1, a trap if anyone later pages this feed.
Separately, and upstream rather than CLI: sortOrder=garbage returns HTTP 500 ({"error":"Application error.","exceptionId":"…"}), not 400, while limit=abc is silently accepted (200). The param is parsed but unvalidated. Worth stating explicitly if the CLI is going to depend on it; I can file that with the Storage API team separately.
Proposal
Add an opt-in --with-last-used flag to token list.
kbagent --json token list --project <alias> --with-last-used
- Opt-in, never default. It is N+1 requests — 25 tokens on the project above means 25 extra calls. Fanning out via the existing
max_parallel_workers keeps it acceptable, but it must not tax the plain token list path.
- JSON: add
lastUsed (ISO 8601, or null) and lastUsedEvent (the event name, useful for telling ext.keboola.mcp-server-tool.* traffic from storage.* traffic) per token. Absent entirely without the flag, so the default shape is unchanged.
- Human mode: one extra
Last used column when the flag is set.
- Sort by recency under the flag — dormant-first (or
--sort if that is preferred); the whole point is scanning for staleness, and creation order does not serve that.
Secondary, and separable if you would rather keep this issue single-purpose: the fixed 6-column table has no way to reach refreshed, which --json does return. A --columns flag (or simply adding Refreshed) would close that. Terminal width is already handleable via COLUMNS=200 kbagent token list, so this is only about column selection, not truncation.
Implementation notes
Per the 3-layer boundaries and the new-command checklist:
- Client (
client/tokens.py): new list_token_events(token_id, limit=1) → GET /v2/storage/tokens/{id}/events, quote(..., safe='') on the id like the neighbouring delete_token/refresh_token. Endpoint path into constants.py if reused.
- Service (
services/token_service.py): the parallel fan-out, the objectId self-lifecycle filter, and the [] → null mapping. This is the business logic and none of it belongs in the command.
- Command (
commands/token.py): the Typer flag, the extra column, formatter.json_mode branch. Thin.
- Permissions:
token.list stays read — the flag adds no side effects, so no new OPERATION_REGISTRY entry, only a re-check that the existing one still covers it.
- REST (
server/routers/token.py): GET /{project}/list gains a matching with_last_used query param, per the 1:1 convention.
- Tests: service-layer (mock the client — cover self-lifecycle-only, empty feed, mixed feed), CLI-layer (
CliRunner, JSON shape + default-shape-unchanged), and an E2E case in tests/test_e2e.py.
- Docs:
AGENT_CONTEXT in commands/context.py, CLAUDE.md command list, make skill-gen, plus the hand-maintained plugin surfaces — references/commands-reference.md and a references/gotchas.md entry tagged (since vX.Y.Z) for the objectId rule and the 6-month retention, since both are things an agent will otherwise get wrong by default.
Happy to open a PR for this if the shape looks right.
Open questions
null vs "unknown". Should a token with [] events report lastUsed: null, or distinguish "never used" from "outside the 6-month window"? created is available to disambiguate: created inside the retention window + no events ⇒ genuinely never used; created older than the window ⇒ unknown. Encoding that as two states seems more honest than one null, but it is a shape decision.
- Depending on an undocumented param. Given
desc is already the default, the CLI could simply omit sortOrder and rely on default ordering — fewer moving parts, no dependency on an unspecified behaviour that 500s on bad input. Preference?
- Master-token scope. A non-master token carrying
canManageTokens was sufficient to read events for other tokens, including master ones. Worth asserting in the E2E test so a future API-side permission tightening surfaces as a test failure rather than as silently empty columns?
- Is
token list the right home, or would this be better as a separate token audit / token events command? The flag keeps the surface small; a dedicated command would give room for --stale-since, --never-used and similar filters.
Context
kbagent token listanswers "what tokens exist" but not "which of them are still in use". There is no last-used signal at any scope today, and the gap is not a CLI omission — the Storage API's token payloads genuinely do not carry one.kbagent docs queryagainst the Storage + Manage specs confirms the split:lastUsed?TokenVerifyResponse,TokenListResponse,TokenCreateResponse,TokenRefreshResponseManageTokenVerifyResponse(Manage API / PAT)+HHMM,nullif never usedSo for project-scoped Storage tokens the value has to be derived. It is derivable:
GET /v2/storage/tokens/{id}/eventsreturns that token's event feed, and its newest entry is an effective last-used timestamp.This issue asks for that derivation to live behind an opt-in flag on
token list, because the raw endpoint has three sharp edges (below) that every caller would otherwise have to rediscover.Why it matters
Token hygiene on a shared project is currently unauditable. On one project in our fleet,
token listreturns 25 tokens — a mix of per-user master tokens, MCP tokens,kbagentdevice tokens and[_internal] Token for triggering …orchestration tokens. Nothing in the output distinguishes a token used four minutes ago from one last used five months ago, so the questions you actually want to answer before revoking anything —token delete?— all require the web UI, one token at a time. #34 established that token identity on a shared project matters; this is the same hygiene problem one step later in the lifecycle.
Deriving it by hand is ~20 lines of Python plus two non-obvious correctness rules, which is exactly the shape of thing that belongs in the CLI rather than in everyone's local scratch script.
Current state (verified on
d8f7a7b(v0.86.0), live API,europe-west3.gcp)No path exists in-tree.
rg lastUsed src/→ 0 matches; no client method touches/tokens/{id}/events:client/tokens.py::list_tokens()(L162) returnsGET /v2/storage/tokensverbatim, and its own docstring enumerates the available fields —id,description,created,expires,isExpired,isMasterToken, thecan*grants,bucketPermissions,creatorToken. No usage data among them.commands/token.pyL86–91 renders a fixed 6-column table (ID,Description,Created,Expires,Master,Created by) with no column-selection flag.permissions.pyL64:"token.list": "read".server/routers/token.pyL35:GET /{project}/listalready exists.sortOrderis honoured on the events endpoint, though it is undocumented there. The public spec lists onlylimit,offset,sinceId,maxId,componentfor/tokens/{id}/events. Testing asc-vs-desc (rather than just checking for a 200) shows it is really applied, and thatdescis already the default:Reads do emit events (
storage.tablesListed,storage.tableDetail,storage.bucketsListed,ext.keboola.mcp-server-tool.*), so coverage across token types is good. A full sweep over all 25 tokens produced a usable recency ranking, from09:54:23today down to one token with no events at all.Three sharp edges the CLI should absorb
1. The feed mixes actions by the token with changes to the token. A freshly minted, never-used token's newest event is its own
storage.tokenCreated, so a naiveevents[0].createdreports it as "used today". Disambiguate onobjectId. Verified both directions on the same event:eventobjectIdtoken.id(who acted)7414128storage.tokenCreated741412872575697257569storage.tokenCreated74141287257569Rule: ignore events where
objectId == <the token's own id>andeventstarts withstorage.token. Applying it to7414128leaves 0 real uses — created, never used — which is the state you most want the audit to surface, and the one a naive read gets exactly backwards.2. Retention is 6 months (per the token-detail Events tab docs). "Unused for >6 months" and "never used" both come back
[]. These should not collapse into the same output — see open questions.3. Events carry
uuid, notid."id" in eventisFalse; the key isuuid(e.g.01a01e1f-5c53-725a-9fe7-56945f67487a). The documentedsinceId/maxIdparams therefore have nothing in the payload to pair with. Harmless atlimit=1, a trap if anyone later pages this feed.Separately, and upstream rather than CLI:
sortOrder=garbagereturns HTTP 500 ({"error":"Application error.","exceptionId":"…"}), not 400, whilelimit=abcis silently accepted (200). The param is parsed but unvalidated. Worth stating explicitly if the CLI is going to depend on it; I can file that with the Storage API team separately.Proposal
Add an opt-in
--with-last-usedflag totoken list.max_parallel_workerskeeps it acceptable, but it must not tax the plaintoken listpath.lastUsed(ISO 8601, ornull) andlastUsedEvent(the event name, useful for tellingext.keboola.mcp-server-tool.*traffic fromstorage.*traffic) per token. Absent entirely without the flag, so the default shape is unchanged.Last usedcolumn when the flag is set.--sortif that is preferred); the whole point is scanning for staleness, and creation order does not serve that.Secondary, and separable if you would rather keep this issue single-purpose: the fixed 6-column table has no way to reach
refreshed, which--jsondoes return. A--columnsflag (or simply addingRefreshed) would close that. Terminal width is already handleable viaCOLUMNS=200 kbagent token list, so this is only about column selection, not truncation.Implementation notes
Per the 3-layer boundaries and the new-command checklist:
client/tokens.py): newlist_token_events(token_id, limit=1)→GET /v2/storage/tokens/{id}/events,quote(..., safe='')on the id like the neighbouringdelete_token/refresh_token. Endpoint path intoconstants.pyif reused.services/token_service.py): the parallel fan-out, theobjectIdself-lifecycle filter, and the[]→nullmapping. This is the business logic and none of it belongs in the command.commands/token.py): the Typer flag, the extra column,formatter.json_modebranch. Thin.token.liststaysread— the flag adds no side effects, so no newOPERATION_REGISTRYentry, only a re-check that the existing one still covers it.server/routers/token.py):GET /{project}/listgains a matchingwith_last_usedquery param, per the 1:1 convention.CliRunner, JSON shape + default-shape-unchanged), and an E2E case intests/test_e2e.py.AGENT_CONTEXTincommands/context.py,CLAUDE.mdcommand list,make skill-gen, plus the hand-maintained plugin surfaces —references/commands-reference.mdand areferences/gotchas.mdentry tagged(since vX.Y.Z)for theobjectIdrule and the 6-month retention, since both are things an agent will otherwise get wrong by default.Happy to open a PR for this if the shape looks right.
Open questions
nullvs "unknown". Should a token with[]events reportlastUsed: null, or distinguish "never used" from "outside the 6-month window"?createdis available to disambiguate:createdinside the retention window + no events ⇒ genuinely never used;createdolder than the window ⇒ unknown. Encoding that as two states seems more honest than onenull, but it is a shape decision.descis already the default, the CLI could simply omitsortOrderand rely on default ordering — fewer moving parts, no dependency on an unspecified behaviour that 500s on bad input. Preference?canManageTokenswas sufficient to read events for other tokens, including master ones. Worth asserting in the E2E test so a future API-side permission tightening surfaces as a test failure rather than as silently empty columns?token listthe right home, or would this be better as a separatetoken audit/token eventscommand? The flag keeps the surface small; a dedicated command would give room for--stale-since,--never-usedand similar filters.