Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "skillhook",
"displayName": "skillhook",
"version": "0.10.0",
"version": "0.10.1",
"description": "Turn this machine into a permanent, secure webhook endpoint that runs Agent Skills with Claude Code or Codex, and run your whole Skillhook Cloud organisation from the agent. Three skills teach the agent to install and expose skillhook, to write good webhook skills and to operate the fleet; two MCP servers manage this machine (skills, secrets, jobs, exposure) and, with an organisation API key, every machine as the Skillhook Cloud dashboard does (what needs a person, failures, stats, answers, replays, runs, hosted URLs).",
"author": {
"name": "Meter",
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "skillhook",
"version": "0.10.0",
"version": "0.10.1",
"description": "Turn this machine into a permanent, secure webhook endpoint that runs Agent Skills with Claude Code or Codex, and run your whole Skillhook Cloud organisation from the agent. Three skills teach the agent to install and expose skillhook, to write good webhook skills and to operate the fleet; two MCP servers manage this machine (skills, secrets, jobs, exposure) and, with an organisation API key, every machine as the Skillhook Cloud dashboard does (what needs a person, failures, stats, answers, replays, runs, hosted URLs).",
"author": {
"name": "Meter",
Expand Down
2 changes: 1 addition & 1 deletion .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "skillhook",
"version": "0.10.0",
"version": "0.10.1",
"description": "Turn this machine into a permanent, secure webhook endpoint that runs Agent Skills with Claude Code or Codex, and run your whole Skillhook Cloud organisation from the agent. Three skills teach the agent to install and expose skillhook, to write good webhook skills and to operate the fleet; two MCP servers manage this machine (skills, secrets, jobs, exposure) and, with an organisation API key, every machine as the Skillhook Cloud dashboard does (what needs a person, failures, stats, answers, replays, runs, hosted URLs).",
"author": {
"name": "Meter",
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to skillhook, newest first. The format follows [Keep a Chang

## Unreleased

## 0.10.1 (2026-10-13)

- Fallback targets accept a runner, model and effort. Recognise Claude session/weekly limits and exhausted credits, ignore synthetic API error notices when checking whether fallback is safe, and never revisit a failed runner in the fallback chain.

## 0.10.0 (2026-10-09)

- `skillhook cloud login` signs in with the browser: it shows a code, opens the cloud's sign-in page, and the person
Expand Down
2 changes: 2 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,3 +414,5 @@ The agent asked a question (`skillhook jobs show <id>` prints it) or finished wi
### The MCP server sees a different home than the CLI

The MCP process reads `SKILLHOOK_HOME` or `--dir` from its own configuration. `skillhook mcp --print-config` adds `--dir` when either is set; make the MCP client and your shell agree.

Fallback runners also accept targets such as `{ runner: codex, model: gpt-6.1-sol, effort: medium }` (JSON uses quoted keys and values). A target overrides the original model and effort when selected; plain runner names keep the original settings.
4 changes: 3 additions & 1 deletion docs/runners.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ Every job that ends `failed` or `timed_out` carries `failure: {kind, code?, retr
```yaml
skillhook:
fallback:
runners: [codex] # in order of preference; shell only for a skill with shell.command
runners: [{runner: codex, model: gpt-6.1-sol, effort: medium}] # in order of preference; shell only for a skill with shell.command
on: [not_ready] # default; add auth, usage_limit, rate_limit, crash to re-run a failed job on the next runner
retry:
attempts: 1 # more runs on the same runner (1–3)
Expand All @@ -190,6 +190,8 @@ skillhook:

`fallback` names other runners for when this one cannot run. With the default `on: [not_ready]` it acts only before the run: the readiness check fails, the first ready runner of the list takes over, and the job records `runner` (the one that ran), `runner_requested` (the one asked for) and `runner_reason` (`fallback: claude not logged in`). `defaults.fallback` in `skillhook.json` applies to every skill that sets none.

Retries on the current runner happen first; once exhausted, fallbacks are considered in list order. Each runner is visited at most once per job (same-runner retries are still allowed). Object targets set their own `model` and `effort`; plain names retain the original settings. Claude synthetic API error messages do not count as agent work, but real assistant messages and tool activity prevent fallback.

The other triggers (`auth`, `usage_limit`, `rate_limit`, `crash`) and `retry` act after a run failed that way, **only when the agent had not produced anything yet** (no assistant message): the failed run becomes an entry of `attempts` (`{runner, started_at, finished_at, status, error, failure}`), the next run starts on the same runner (`retry`, after `backoff_seconds`) or on the next ready fallback runner, and the job record is the last attempt. A run that had already started acting is never repeated, because a repeat could redo its side effects; both features are off by default and belong on idempotent skills only. Each attempt gets the full `timeout_seconds`; `stdout.log` / `stderr.log` keep every attempt, separated by `--- attempt N (<runner>) ---`.

## Environment
Expand Down
2 changes: 2 additions & 0 deletions docs/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -554,3 +554,5 @@ skillhook:

Not used by the shell runner; document what handle.py does here.
```

Fallback runners also accept targets such as `{ runner: codex, model: gpt-6.1-sol, effort: medium }` (JSON uses quoted keys and values). A target overrides the original model and effort when selected; plain runner names keep the original settings.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@meterapp/skillhook",
"version": "0.10.0",
"version": "0.10.1",
"description": "Make your Agent Skills reactive. A permanent, secure webhook endpoint on your own machine that runs SKILL.md files with Claude Code or Codex the moment something happens, plus version-controlled schedules for the work that has no trigger. Webhook in, agent out.",
"license": "MIT",
"author": "Meter <hello@meterapp.co> (https://meterapp.co)",
Expand Down
37 changes: 32 additions & 5 deletions schema/skillhook.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,38 @@
"minItems": 1,
"type": "array",
"items": {
"type": "string",
"enum": [
"claude",
"codex",
"shell"
"anyOf": [
{
"type": "string",
"enum": [
"claude",
"codex",
"shell"
]
},
{
"type": "object",
"properties": {
"runner": {
"type": "string",
"enum": [
"claude",
"codex",
"shell"
]
},
"model": {
"type": "string"
},
"effort": {
"type": "string"
}
},
"required": [
"runner"
],
"additionalProperties": false
}
]
}
},
Expand Down
37 changes: 32 additions & 5 deletions schema/skillhook.yaml.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -551,11 +551,38 @@
"minItems": 1,
"type": "array",
"items": {
"type": "string",
"enum": [
"claude",
"codex",
"shell"
"anyOf": [
{
"type": "string",
"enum": [
"claude",
"codex",
"shell"
]
},
{
"type": "object",
"properties": {
"runner": {
"type": "string",
"enum": [
"claude",
"codex",
"shell"
]
},
"model": {
"type": "string"
},
"effort": {
"type": "string"
}
},
"required": [
"runner"
],
"additionalProperties": false
}
]
}
},
Expand Down
2 changes: 2 additions & 0 deletions skills/skillhook-authoring/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,5 @@ To iterate on a draft before installing it, or to try a change without touching
6. Configure the sender (`skillhook url <name>` plus the secret), trigger one real event, and watch `skillhook jobs list`. Read `result.md` of the first few jobs and tighten the body wherever the agent guessed. Every delivery the server received, including the ones it rejected or skipped, is in `skillhook deliveries list`, and `skillhook deliveries replay <id>` (MCP: `replay_delivery`) runs it again through the skill as it is now, so a real event is the test payload of the next iteration. On a machine paired with Skillhook Cloud the same history and replay cover every machine, from the dashboard, its API, the `skillhook-cloud` MCP server (`replay_delivery`) or `skillhook cloud replay_delivery <id>`, on the machine it arrived at, on another machine or through another skill: https://skillhook.dev/docs/deliveries.

Keep SKILL.md under about 150 lines; move API shapes, field lists and long procedures into `references/*.md` and tell the agent when to read them.

Fallback runners also accept targets such as `{ runner: codex, model: gpt-6.1-sol, effort: medium }` (JSON uses quoted keys and values). A target overrides the original model and effort when selected; plain runner names keep the original settings.
31 changes: 19 additions & 12 deletions src/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { answerQuestion, readTimeline, type JobAnswer, type JobQuestion, type Pr
import { deriveOutcome, resolveJobResponse } from "./response.js";
import type { ReadinessCache, RunnerReadiness } from "./readiness.js";
import { prepareRun } from "./run.js";
import { classifyFailure, type FailureKind, type FallbackTrigger, type JobFailure } from "./runners/failure.js";
import { classifyFailure, type FailureKind, type FallbackConfig, type FallbackTrigger, type JobFailure } from "./runners/failure.js";
import type { RunnerOutcome, StreamState } from "./runners/index.js";
import { lastLines } from "./runners/types.js";
import type { SkillRegistry } from "./registry.js";
Expand Down Expand Up @@ -46,7 +46,7 @@ interface AttemptResult {
}

/** The skill's `fallback:` (else `defaults.fallback`), with the default trigger. */
function fallbackPolicy(skill: Skill, config: Config): { runners: RunnerName[]; on: FallbackTrigger[] } {
function fallbackPolicy(skill: Skill, config: Config): { runners: FallbackConfig["runners"]; on: FallbackTrigger[] } {
const spec = skill.config.fallback ?? config.defaults.fallback;
return { runners: spec?.runners ?? [], on: spec?.on ?? ["not_ready"] };
}
Expand Down Expand Up @@ -300,13 +300,15 @@ export class JobQueue extends EventEmitter {
}

/** The first usable runner of `candidates` other than `exclude` (`shell` only for a skill that has a command). */
private async firstReady(candidates: RunnerName[], exclude: RunnerName, skill: Skill): Promise<RunnerReadiness | undefined> {
for (const candidate of candidates) {
if (candidate === exclude) continue;
private async firstReady(candidates: FallbackConfig["runners"], exclude: Set<RunnerName>, skill: Skill): Promise<{ runner: RunnerName; model?: string; effort?: string } | undefined> {
for (const entry of candidates) {
const target = typeof entry === "string" ? { runner: entry } : entry;
const candidate = target.runner;
if (exclude.has(candidate)) continue;
if (candidate === "shell" && !skill.config.shell?.command) continue;
try {
const readiness = await this.deps.readiness?.get(candidate);
if (readiness?.ready) return readiness;
if (readiness?.ready) return target;
} catch {
/* a probe that fails is not a ready runner */
}
Expand Down Expand Up @@ -338,6 +340,7 @@ export class JobQueue extends EventEmitter {

// Pre-flight: a runner that is not installed or not logged in never spawns; a fallback takes over or the job fails fast.
const policy = fallbackPolicy(skill, config);
const tried = new Set<RunnerName>([running.job.runner]);
if (this.deps.readiness && running.job.runner !== "shell") {
let readiness: RunnerReadiness | undefined;
try {
Expand All @@ -346,14 +349,15 @@ export class JobQueue extends EventEmitter {
logger.warn("readiness check failed; running anyway", { job: job.id, runner: running.job.runner, error: errorMessage(error) });
}
if (readiness && !readiness.ready) {
const alternative = policy.on.includes("not_ready") ? await this.firstReady(policy.runners, running.job.runner, skill) : undefined;
const alternative = policy.on.includes("not_ready") ? await this.firstReady(policy.runners, tried, skill) : undefined;
if (!alternative) {
this.finish(running.job, { status: "failed", started_at: nowIso(), error: `${running.job.runner} is not ready: ${readiness.detail}${readiness.hint ? ` (${readiness.hint})` : ""}`, failure: { kind: readiness.found ? "auth" : "not_found", retryable: false, message: readiness.detail } });
return;
}
logger.warn("runner not ready; using the fallback", { job: job.id, skill: job.skill, runner: running.job.runner, fallback: alternative.runner, detail: readiness.detail });
running.job = store.update(job.id, { runner: alternative.runner, runner_requested: running.job.runner, runner_reason: `fallback: ${running.job.runner} ${readiness.detail}` });
this.events.emit("job.updated", { job: running.job, fields: ["runner", "runner_requested", "runner_reason"] });
tried.add(alternative.runner);
running.job = store.update(job.id, { runner: alternative.runner, ...(alternative.model ? { model: alternative.model } : {}), ...(alternative.effort ? { effort: alternative.effort } : {}), runner_requested: running.job.runner, runner_reason: `fallback: ${running.job.runner} ${readiness.detail}` });
this.events.emit("job.updated", { job: running.job, fields: ["runner", "model", "effort", "runner_requested", "runner_reason"] });
}
}

Expand All @@ -367,16 +371,19 @@ export class JobQueue extends EventEmitter {
if (kind && (attempt.status === "failed" || attempt.status === "timed_out") && !attempt.producedOutput && !running.cancelled && !this.stopping) {
if (kind === "auth") this.deps.readiness?.invalidate(running.job.runner);
const sameRunner = retriesLeft > 0 && retryOn.includes(kind);
const alternative = !sameRunner && (policy.on as string[]).includes(kind) ? await this.firstReady(policy.runners, running.job.runner, skill) : undefined;
const alternative = !sameRunner && (policy.on as string[]).includes(kind) ? await this.firstReady(policy.runners, tried, skill) : undefined;
if (sameRunner || alternative) {
attempts.push({ runner: running.job.runner, started_at: attempt.startedAt, finished_at: nowIso(), status: attempt.status, ...(attempt.error ? { error: attempt.error } : {}), failure: attempt.failure });
const fields: (keyof JobRecord)[] = ["attempts"];
const patch: Partial<JobRecord> = { attempts: [...attempts] };
if (alternative) {
tried.add(alternative.runner);
patch.runner = alternative.runner;
if (alternative.model) patch.model = alternative.model;
if (alternative.effort) patch.effort = alternative.effort;
patch.runner_requested = running.job.runner_requested ?? running.job.runner;
patch.runner_reason = `fallback: ${running.job.runner} failed (${kind})`;
fields.push("runner", "runner_requested", "runner_reason");
fields.push("runner", "model", "effort", "runner_requested", "runner_reason");
}
running.job = store.update(job.id, patch);
this.events.emit("job.updated", { job: running.job, fields });
Expand Down Expand Up @@ -567,7 +574,7 @@ export class JobQueue extends EventEmitter {
status,
failure,
error,
producedOutput: Boolean(state.lastMessage),
producedOutput: Boolean(state.producedOutput || state.lastMessage),
startedAt,
patch: {
...(outcomeOverride ? { outcome: outcomeOverride } : {}),
Expand Down
3 changes: 2 additions & 1 deletion src/runners/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ export const claudeRunner: Runner = {
return;
}
if (typeof event.session_id === "string" && !state.sessionId) state.sessionId = event.session_id;
if (event.type === "assistant") {
if (event.type === "assistant" && event.is_api_error_message !== true) {
state.producedOutput = true;
const text = extractText(event.message);
if (text) state.lastMessage = text;
}
Expand Down
4 changes: 4 additions & 0 deletions src/runners/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ export const codexRunner: Runner = {
case "thread.started":
if (typeof event.thread_id === "string") state.sessionId = event.thread_id;
break;
case "item.started":
state.producedOutput = true;
break;
case "item.completed": {
state.producedOutput = true;
const item = event.item as { type?: string; text?: string } | undefined;
if (item?.type === "agent_message" && typeof item.text === "string") state.lastMessage = item.text;
break;
Expand Down
3 changes: 3 additions & 0 deletions src/runners/failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ describe("classifyFailure", () => {
expect(classifyFailure({ status: "failed", error: "simulated failure", exitCode: 1, signal: null, resultEvent: { subtype: "error", is_error: true }, reported: true })).toEqual({ kind: "unknown", code: "error", retryable: false, message: "simulated failure" });
// The last stderr lines count when the error text says nothing.
expect(classifyFailure({ status: "failed", error: "claude exited with code 1", exitCode: 1, signal: null, stderr: "Error: Invalid API key · Please run /login" })).toMatchObject({ kind: "auth" });
for (const error of ["You've hit your session limit · resets 6pm (America/New_York)", "You've hit your weekly limit", "Credit balance is too low", "Out of credits"]) {
expect(classifyFailure({ status: "failed", error, exitCode: 1, signal: null, reported: true })).toMatchObject({ kind: "usage_limit", retryable: false });
}
expect(FAILURE_KINDS).toHaveLength(9);
});

Expand Down
Loading
Loading