Skip to content

fix: make the zendesk triage call via claude work over API - #37

Open
Bilb wants to merge 16 commits into
mainfrom
fix-zendesk-over-api
Open

fix: make the zendesk triage call via claude work over API#37
Bilb wants to merge 16 commits into
mainfrom
fix-zendesk-over-api

Conversation

@Bilb

@Bilb Bilb commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

Bilb added 16 commits August 7, 2026 07:19
--effort was parsed and then only ever reached the API path, so a
`--backend claude-cli` run silently ignored it and used the CLI's own
session default. The flag now maps onto `claude --effort`.
The CLI path used to describe the shape in prose and parse a JSON object
back out of the model's reply, which is how platform, app_version and
reported_session_id silently came back empty after being added to the
schema. `claude --json-schema` takes the same SCHEMA the API path uses
and returns it validated in the envelope's `structured_output`, so the
hand-maintained field list (and the tests guarding it) can go.

An envelope with no `structured_output` means the CLI ignored the flag
(pre-v2.1.205) — that exits with the version to check rather than
rendering an empty digest.
The CLI resolves an alias against whatever the authenticated plan allows,
so a new Opus release needs no edit here and a plan without Opus access
degrades instead of 404-ing on an id it can't serve. Documents the
trade-off and when to pin a full id instead.

Note: `--backend api` needs an explicit `--model claude-opus-4-8` until
the follow-up commit removes that path — the Anthropic API takes ids, not
Claude Code aliases.
Installs Claude Code on the runner (stable channel, version echoed into
the log) and authenticates with a CLAUDE_CODE_OAUTH_TOKEN secret from
`claude setup-token`, so the job runs on a Claude subscription rather
than API billing.

ANTHROPIC_API_KEY is dropped from the step rather than left alongside:
an API key outranks the OAuth token in Claude Code's credential
precedence, and in -p mode a present key is always used, so keeping it
would silently bill the API. DISABLE_AUTOUPDATER pins the run to the
version the log reports.

Documents the token's one-year life, that runs draw on one person's
subscription limits, and why --bare must not be added (it reads
ANTHROPIC_API_KEY only and never OAuth credentials).
Nothing uses it now that the scheduled run goes through Claude Code, and
keeping it meant maintaining two classification paths plus a dependency
the job never loads. Removes analyze(), the anthropic import and pin, and
the `api` backend choice — `claude-cli` becomes the default, leaving
`file` for rendering findings classified elsewhere.

The one behaviour that went with it was the explicit max-tokens message
on a truncated batch, since only the API path could read stop_reason. A
truncated reply now closes no JSON and yields no structured_output, so
that error names both possible causes and the --batch-size to lower.
Brings back analyze(), the anthropic dependency and the `api` backend, and
teaches the workflow to choose: `api` when an ANTHROPIC_API_KEY secret
exists, `claude-cli` otherwise, overridable per dispatch. Adding the
secret therefore switches the scheduled job over with no workflow edit,
and the job keeps running on the subscription token until then.

Because both credentials are in scope in the run step, it unsets the one
the chosen backend doesn't use — an API key outranks the OAuth token
inside `claude`, so leaving it set would make a claude-cli run bill the
API instead. The Claude Code install is skipped entirely on the API path.

DEFAULT_MODEL stays the `opus` alias, so the API path maps aliases to ids
via API_MODEL_ALIASES (newest model per family, matching what the CLI's
own alias resolution lands on).
Ticket text is written by strangers and the runner has a checkout of this
repo, so the CLI ran with more attached to it than the job needs: the
instructions rode on stdin next to the untrusted payload, and the session
loaded the runner's and the repo's hooks, plugins, skills and CLAUDE.md
along with the full tool surface.

Now: SYSTEM_PROMPT travels as --system-prompt (stdin carries only the
ticket JSON), --setting-sources "" loads no config from either machine,
--strict-mcp-config with no config means no MCP servers, and the tool
surface is denied by name. A session ends up with one tool,
StructuredOutput, and no MCP servers. Dropping the agent preamble and the
tool definitions also took a two-ticket fixture from ~$0.29 to ~$0.015,
so the README's cost comparison is corrected too.

Measured on v2.1.218, and the reason the deny list is by name rather than
`*`: a wildcard also denies StructuredOutput, which is how --json-schema
is implemented, so the run comes back as prose. `--permission-mode
dontAsk` is not a substitute either — a session with no allow rules still
executed Bash(echo …). Tests guard the parts that fail silently.
Drops the backend selection step, the Claude Code install and the
CLAUDE_CODE_OAUTH_TOKEN secret from the workflow: CI now always passes
--backend api. That keeps the runner off a ~270MB CLI download and keeps
CI on an org-owned credential rather than an individual's subscription.

The claude-cli backend and its lockdown stay in the script as the local
default, so a local run still needs no Claude credential. Its notes move
to the local-testing section, including the one that bites there instead
of in CI: an exported ANTHROPIC_API_KEY outranks your Claude Code login,
so a claude-cli run with one in the shell bills the API instead.
Replaces the `opus` alias with the id. An alias resolves to whatever the
credential's newest Opus is, which would move severity calibration and
cluster labels on someone else's release schedule — not what an unattended
daily digest wants, since no one is watching a run to notice the shift.

ZENDESK_TRIAGE_MODEL and --model stay as overrides and still accept
aliases (sonnet for a large backfill), so API_MODEL_ALIASES stays too. The
default lives in the script rather than in a repo variable so there is one
place to change it; the workflow's vars.ZENDESK_TRIAGE_MODEL can stay
unset. Documents why Opus and why pinned, with the cost figures that make
the tier choice a non-question at this volume.

Verified the CLI accepts the full id on a subscription login (modelUsage
reports claude-opus-5), so the local default works too, and the test now
asserts the invariant that matters: DEFAULT_MODEL must resolve to an API
id, whether it's a pin or an alias.
One way to reach Claude instead of two. The CLI path existed because there
was no API key; now that there is one, keeping it meant maintaining a
second classification path, its lockdown flags, and a deny list that
needs re-checking on every CLI upgrade — for a path nothing runs.

Gone with it: analyze_via_claude_cli, findings_from_cli_envelope, the
CLI_ISOLATION_ARGS/CLI_DENIED_TOOLS pair, extract_json_object (only the
CLI path parsed JSON out of prose), the subprocess import, and 15 tests.
--backend keeps `api` (now the default, so the workflow stops passing it)
and `file` for rendering findings classified elsewhere.

The model shorthands stay: ZENDESK_TRIAGE_MODEL=sonnet is documented for
backfills and would otherwise 404 as a bare alias.

Verified with a local dry run against the API on real tickets, and 158
tests pass.
--backend only had two values left, and one of them was inseparable from
--findings: `--backend file --findings X` had to be passed together, with
a validation branch to catch half of it. `--findings X` now says the whole
thing on its own, and the flag it replaced is gone along with that check.

Verified both branches: --findings renders a hand-written file with no
Zendesk or Anthropic credentials in the environment, and a live dry run
still fetches and classifies.
The digest is read by skimming, and an embed gave every field its own
labelled box — sixteen of those is a wall, and Discord's 10-embeds-per-
message cap fanned a busy day out over several posts for no reason.

Each ticket is now a line:

    🔥 | 🐞 | #27605 · Notifications only appear after opening the app |
    Likely cause: Background push service not waking client

leading with a severity marker (🚨 for the urgent categories, which the
model rates not_applicable because they aren't bugs), then the category
emoji, a masked link on the id, the summary, and the root-cause guess. An
abuse report keeps carrying its reported Session ID, which is the one
field on the old embed worth the characters.

The header keeps the coverage accounting that stops a truncated run from
reading as a quiet day, now with a compact category tally and a duplicate-
cluster line. Chunking moves from Discord's 6,000-char embed budget to the
2,000-char content limit, counting the newlines that join lines; per-message
ticket-id coverage is unchanged, so partial-failure recovery still works.

Category colours went with the embeds: CATEGORY_SPECS now carries an
`urgent` flag where the colour was, and the emoji is derived from the
label so the table stays the one place a category is described.
Two changes to the same line.

The lines now travel inside a single embed description rather than
message content, purely for the character budget: content caps at 2,000,
a description at 4,096. A typical day is ~16 lines at ~180 characters —
55 of which is the masked link on the id — so that is the difference
between two messages and one. No `fields` are used; the embed is a bigger
text box with a coloured border, orange when something is worth looking
into and green when nothing is. Chunking now counts against 4,096, and
the first embed carries the title so the rest read as continuations.

Each line also gets a platform icon between the category and the id, so
"is this mine?" is answerable without reading the summary: 🤖 Android,
🍎 iOS, 🖥️ desktop, 🌐 multiple, ❔ unknown. The three desktop platforms
share an icon because the distinction rarely changes who picks a ticket
up, and a test asserts every PLATFORMS value has an entry.
actions/cache/{restore,save}@v4 target Node 20, which the runners now
force onto Node 24 with a deprecation warning on every run. v5 was the
node24 migration and v6 an internal ESM change; neither alters the inputs
this workflow passes, and GitHub-hosted runners are well past the 2.327.1
minimum v5+ requires.

These were the last Node 20 actions in the repo — everything else is
already on a current major (checkout@v7, setup-python@v6, and so on).
The lines carry their own structure, so the embed was contributing a
border and a title. Back to message content, with the 🗂️ header line
leading the first message instead of an embed title, and no colour.

The trade-off is the character budget, and it is real: content caps at
2,000 where an embed description got 4,096, and a masked link on a ticket
id spends 54 characters the reader never sees. Rebuilding last night's
run — 16 analyzed, 9 worth looking into — comes to ~2,400 characters, so
it arrives as two messages (1,781 + 641) where the embed held it in one.
Chunking, clipping and per-message ticket coverage are otherwise unchanged.

Platform icons stay.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant