Both Claude Code and Codex now have /goal: you give a session a condition that can be checked, and the agent keeps working round after round until the condition is met. It is a good fit for long, well-defined jobs such as "get every unit test passing" or "first paint under 1.5 s and Lighthouse above 90", the kind of task where an agent would otherwise stop halfway and write a confident summary.

The interesting part for tool builders is that the two agents record goals in completely different places. This post walks through both formats, using the implementation in Kiteline, an open-source desktop app (Mac, Windows, Linux) that follows Claude Code, Codex and other agent sessions and shows their progress on a phone. All the code mentioned below is in watcher/kiteline-watch.py.

A session with an active goal: the goal bar above the message box shows the round, time spent and the last check

Claude Code: goal_status attachments in the transcript

Claude Code writes each session as a JSONL transcript. Once a goal is set, extra lines appear with type: "attachment" and attachment.type: "goal_status". The fields tell you what happened:

Line Meaning
{met: false, sentinel: true, condition} Goal set
{met: true, sentinel: true} Goal cleared (/goal clear, or an unrecoverable error)
{met: false, condition, reason} One check: not met yet, reason says what is missing
{met: true, condition, reason, iterations, durationMs, tokens} Goal met, with rounds, time and tokens
{failed: true, …} The check decided the goal can't be met

The check at the end of each round is itself a model call that judges the condition and explains why. Pauses show up as system lines instead, such as Goal paused · <cause> · … or Goal still active · <cause> · retrying in N min, which are easy to match:

_GOAL_PAUSED_RE = re.compile(r"^Goal paused(?: after (\d+) automatic retries)? · (.+?) · ") _GOAL_RETRY_RE = re.compile(r"^Goal still active · (.+?) · retrying in (\d+) min") _GOAL_ERROR_RE = re.compile(r"^Goal cleared after an unrecoverable error \((.+?)\)") 

A small state machine (ClaudeGoal) consumes these lines: the "set" line opens a goal, every check bumps checks and stores the reason, and met / failed / cleared change the state. If the transcript was truncated or resumed and the "set" line is missing, it picks up from the first check it sees.

Codex: a thread_goals table in local SQLite

Codex does not put goals in the rollout file. They live in goals_N.sqlite under CODEX_HOME, one row per thread:

select thread_id, objective, status, token_budget, tokens_used, time_used_seconds, created_at_ms, updated_at_ms from thread_goals 

status is one of active, paused, blocked, usage_limited, budget_limited or complete. Two details matter when you read this from another process:

  • Open it read-only (sqlite3.connect("file:...?mode=ro", uri=True)) so you never compete with Codex for the write lock.
  • Cache by modification time of both the database file and its -wal file. If neither changed, reuse the last result, so polling every second costs nothing. If the table doesn't exist (an older Codex), remember that until the file changes.

One snapshot for every agent

Both sources are turned into the same shape, and every UI (phone, watch, desktop) only reads that:

{"text": "Retry failed payment callbacks automatically…", "state": "active", "checks": 2, "reason": "1 case still fails: a duplicate callback for the same order is booked twice"} 

On the phone this becomes "Goal · round 3" in the session list, and a goal bar above the message box with the round, the time spent and the last check:

Goal details: progress, time and what the last check said is still missing

The hard part: deciding that a goal has stopped

Reading the formats is easy. Deciding that a goal has stopped is not, and a wrong answer means a confusing push notification.

  1. Sessions look idle between rounds. The end-of-round check is a model call that can take a while, and during it the session looks idle. Treating "idle" as "stopped" would fire a notification every round. Kiteline only trusts explicit signals (the pause lines above, or a [Request interrupted by user] marker) and otherwise waits for two minutes of idleness before calling a goal paused.
  2. Reading one step behind. Early on, goal state was updated together with the general transcript reader. With very fast rounds, the "should we notify?" decision sometimes used the previous goal and announced a "done" that wasn't. ClaudeGoal now polls the file itself from a byte offset (starting over if the file was rewritten and got shorter), so the snapshot always has the latest state.
  3. "Done" after every round. A goal can run dozens of rounds overnight. While a goal is active, the per-round "finished" notification is suppressed; you only hear about it when it is met, can't be met, or stops.

A goal that was achieved after 4 rounds

Takeaways

  • Claude Code keeps goals in goal_status attachment lines in the transcript; Codex keeps them in the thread_goals table of a local SQLite file.
  • Normalise both into one snapshot and let the UI read only that.
  • For "stopped", prefer explicit signals and wait long enough otherwise. Reporting late is better than reporting wrong.

The full implementation (ClaudeGoal, GoalBook, codex_goals, goal_command) is in hon6/kiteline. Claude Code is a product of Anthropic and Codex is a product of OpenAI; Kiteline is an independent third-party tool.