Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,21 @@ outputs:
session_id:
description: "The Claude Code session ID that can be used with --resume to continue this conversation"
value: ${{ steps.run.outputs.session_id }}
input_tokens:
description: "Number of input tokens used in this run"
value: ${{ steps.run.outputs.input_tokens }}
output_tokens:
description: "Number of output tokens generated in this run"
value: ${{ steps.run.outputs.output_tokens }}
cache_read_tokens:
description: "Number of tokens read from the prompt cache"
value: ${{ steps.run.outputs.cache_read_tokens }}
cache_write_tokens:
description: "Number of tokens written to the prompt cache"
value: ${{ steps.run.outputs.cache_write_tokens }}
turns:
description: "Number of agent turns in this run"
value: ${{ steps.run.outputs.turns }}

runs:
using: "composite"
Expand Down
24 changes: 24 additions & 0 deletions base-action/src/run-claude-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { readFile, writeFile, access } from "fs/promises";
import { dirname, join } from "path";
import { query } from "@anthropic-ai/claude-agent-sdk";
import type {
SDKAssistantMessage,
SDKMessage,
SDKResultMessage,
SDKUserMessage,
Expand All @@ -14,6 +15,11 @@ export type ClaudeRunResult = {
sessionId?: string;
conclusion: "success" | "failure";
structuredOutput?: string;
inputTokens?: number;
outputTokens?: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
numTurns?: number;
};

const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
Expand Down Expand Up @@ -156,6 +162,10 @@ export async function runClaudeWithSdk(

const messages: SDKMessage[] = [];
let resultMessage: SDKResultMessage | undefined;
let totalInputTokens = 0;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's your opinion about having just one const object and updating its props?

I feel it seems more structured but your approach is already fine

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm suggesting something like

const budget = {
    tokens: {
      input: 0,
      output: 0,
    },
    cache: {
      read: 0,
      write: 0,
  }
} satisfies TYPE

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Appreciate the suggestion! The flat variables feel more conventional here -- the rest of the codebase uses the same pattern (e.g. resultMessage, commentId, claudeBranch are all individual let declarations rather than grouped objects). A nested object would also require introducing a new type just for internal accumulation, without adding much clarity given these four values flow into separate fields on ClaudeRunResult right after.

let totalOutputTokens = 0;
let totalCacheReadTokens = 0;
let totalCacheWriteTokens = 0;

try {
for await (const message of query({ prompt, options: sdkOptions })) {
Expand All @@ -169,6 +179,14 @@ export async function runClaudeWithSdk(
if (message.type === "result") {
resultMessage = message as SDKResultMessage;
}

if (message.type === "assistant") {
const usage = (message as SDKAssistantMessage).message.usage;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this casting is used above this line, maybe moving out and using the same casting for both branches seems cleaner.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two casts are for different types in different branches of the same discriminated union -- SDKResultMessage when message.type === "result" and SDKAssistantMessage when message.type === "assistant". Moving them outside would mean casting message to two different types before the branches even run, which doesn't model the intent. Keeping each cast inside its own type-narrowed branch is the standard TypeScript pattern here.

totalInputTokens += usage.input_tokens ?? 0;
totalOutputTokens += usage.output_tokens ?? 0;
totalCacheReadTokens += usage.cache_read_input_tokens ?? 0;
totalCacheWriteTokens += usage.cache_creation_input_tokens ?? 0;
}
}
} catch (error) {
console.error("SDK execution error:", error);
Expand Down Expand Up @@ -205,6 +223,12 @@ export async function runClaudeWithSdk(
const isSuccess = resultMessage.subtype === "success";
result.conclusion = isSuccess ? "success" : "failure";

result.inputTokens = totalInputTokens;
result.outputTokens = totalOutputTokens;
result.cacheReadTokens = totalCacheReadTokens;
result.cacheWriteTokens = totalCacheWriteTokens;
result.numTurns = resultMessage.num_turns;

// Handle structured output
if (hasJsonSchema) {
if (
Expand Down
79 changes: 79 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,85 @@ See `examples/test-failure-analysis.yml` for a working example that:
For complete details on JSON Schema syntax and Agent SDK structured outputs:
https://docs.claude.com/en/docs/agent-sdk/structured-outputs

## Tracking Token Usage and Cost

`claude-code-action` exposes token usage as step outputs after each run:

| Output | Description |
|--------|-------------|
| `input_tokens` | Tokens in the prompt |
| `output_tokens` | Tokens generated by Claude |
| `cache_read_tokens` | Tokens read from the prompt cache |
| `cache_write_tokens` | Tokens written to the prompt cache |
| `turns` | Number of agent turns in the run |

### Logging token usage

```yaml
- uses: anthropics/claude-code-action@v1
id: claude
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt: "Review this PR for security issues"

- name: Log token usage
run: |
echo "Input tokens: ${{ steps.claude.outputs.input_tokens }}"
echo "Output tokens: ${{ steps.claude.outputs.output_tokens }}"
echo "Cache read tokens: ${{ steps.claude.outputs.cache_read_tokens }}"
echo "Cache write tokens: ${{ steps.claude.outputs.cache_write_tokens }}"
echo "Turns: ${{ steps.claude.outputs.turns }}"
```

### Budget enforcement

```yaml
- uses: anthropics/claude-code-action@v1
id: claude
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt: "..."

- name: Check cost threshold
env:
INPUT_TOKENS: ${{ steps.claude.outputs.input_tokens }}
OUTPUT_TOKENS: ${{ steps.claude.outputs.output_tokens }}
run: |
# claude-sonnet-4-5: $3/1M input, $15/1M output (adjust for your model)
COST=$(echo "scale=4; ($INPUT_TOKENS * 3 + $OUTPUT_TOKENS * 15) / 1000000" | bc)
echo "Estimated cost: \$$COST"
if (( $(echo "$COST > 0.50" | bc -l) )); then
echo "::error::Run exceeded $0.50 cost threshold"
exit 1
fi
```

### Sending to a cost dashboard

For teams tracking spend across many agent workflow runs, the token outputs can be
forwarded to a cost tracking tool. [AgentMeter](https://agentmeter.app) is a
GitHub-native cost dashboard built for this — it receives token counts from your
workflow and shows per-run cost, per-repo spend trends, and budget alerts.

```yaml
- uses: anthropics/claude-code-action@v1
id: claude
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt: "..."

- uses: AgentMeter/agentmeter-action@v1
with:
api_key: ${{ secrets.AGENTMETER_API_KEY }}
model: claude-sonnet-4-5
input_tokens: ${{ steps.claude.outputs.input_tokens }}
output_tokens: ${{ steps.claude.outputs.output_tokens }}
cache_read_tokens: ${{ steps.claude.outputs.cache_read_tokens }}
cache_write_tokens: ${{ steps.claude.outputs.cache_write_tokens }}
turns: ${{ steps.claude.outputs.turns }}
status: ${{ job.status }}
```

## Ways to Tag @claude

These examples show how to interact with Claude using comments in PRs and issues. By default, Claude will be triggered anytime you mention `@claude`, but you can customize the exact trigger phrase using the `trigger_phrase` input in the workflow.
Expand Down
18 changes: 18 additions & 0 deletions src/entrypoints/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,24 @@ async function run() {
core.setOutput("structured_output", claudeResult.structuredOutput);
}
core.setOutput("conclusion", claudeResult.conclusion);
if (claudeResult.inputTokens !== undefined) {
core.setOutput("input_tokens", String(claudeResult.inputTokens));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we really need to transform intro string?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes -- core.setOutput signature is (name: string, value: string) and the fields on ClaudeRunResult are number | undefined, so TypeScript requires the conversion. Passing the number directly would be a type error.

}
if (claudeResult.outputTokens !== undefined) {
core.setOutput("output_tokens", String(claudeResult.outputTokens));
}
if (claudeResult.cacheReadTokens !== undefined) {
core.setOutput("cache_read_tokens", String(claudeResult.cacheReadTokens));
}
if (claudeResult.cacheWriteTokens !== undefined) {
core.setOutput(
"cache_write_tokens",
String(claudeResult.cacheWriteTokens),
);
}
if (claudeResult.numTurns !== undefined) {
core.setOutput("turns", String(claudeResult.numTurns));
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Only mark as prepare failure if we haven't completed the prepare phase
Expand Down