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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Fizzy API
FIZZY_API_TOKEN=fz_your_token_here
FIZZY_ACCOUNT_SLUG=your_account_slug
FIZZY_API_URL=https://app.fizzy.do

# Agent backends (provide at least one)
ANTHROPIC_API_KEY=sk-ant-your-key-here
Expand Down
44 changes: 44 additions & 0 deletions .github/workflows/build-package.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Build package asset

on:
push:
branches:
- self-hosted
Comment thread
joshyorko marked this conversation as resolved.
- main
pull_request:
workflow_dispatch:

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Test
run: npm test

- name: Typecheck
run: npm run typecheck

- name: Build
run: npm run build

- name: Pack npm tarball
run: npm pack

- name: Upload package artifact
uses: actions/upload-artifact@v7
with:
name: fizzy-popper-package
path: fizzy-popper-*.tgz
if-no-files-found: error
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ This is the same approach OpenAI took with Symphony: spec first, implementation
│ Agent │ │ Agent │
│ #agent- │ │ #agent- │
│ instructions│ │ instructions│
│ #claude │ │ #anthropic │
│ #codex │ │ #anthropic │
│ #move-to- │ │ #close-on- │
│ code-review │ │ complete │
└──────────────┘ └──────────────┘
Expand Down Expand Up @@ -54,15 +54,15 @@ You need three things: a board, a golden ticket card, and a work card to test wi

```bash
# Create a board with columns
fizzy board create --title "Agent Playground"
fizzy column create --board BOARD_ID --title "Triage"
fizzy column create --board BOARD_ID --title "Done"
fizzy board create --name "Agent Playground"
fizzy column create --board BOARD_ID --name "Triage"
fizzy column create --board BOARD_ID --name "Done"

# Create a golden ticket in the Triage column
fizzy card create --board BOARD_ID --title "Triage Agent" \
--description "Summarize the card and propose a plan of action as a bulleted list."
fizzy card tag CARD_NUMBER --tag agent-instructions
fizzy card tag CARD_NUMBER --tag claude
fizzy card tag CARD_NUMBER --tag codex
fizzy card tag CARD_NUMBER --tag move-to-done
Comment thread
joshyorko marked this conversation as resolved.
fizzy card column CARD_NUMBER --column TRIAGE_COLUMN_ID

Expand All @@ -77,9 +77,9 @@ fizzy card create --board BOARD_ID --title "Add user authentication" \
fizzy card column CARD_NUMBER --column TRIAGE_COLUMN_ID
```

**Or in the Fizzy UI:** Create a card, tag it `#agent-instructions` and `#claude`, write your prompt in the description, add checklist items as steps, and drag it into the column you want to automate. Then drag a work card into that column and watch the agent go.
**Or in the Fizzy UI:** Create a card, tag it `#agent-instructions` plus a backend tag like `#codex` or `#claude`, write your prompt in the description, add checklist items as steps, and drag it into the column you want to automate. Then drag a work card into that column and watch the agent go.

**With Claude Code:** Run `/setup-test-board` — there's a built-in skill that walks you through the whole thing using the Fizzy CLI.
**With an agent CLI:** Run `/setup-test-board` — there's a built-in skill that walks you through the whole thing using the Fizzy CLI. Use `#codex` for Codex, `#claude` for Claude Code, or another supported backend tag.

## Golden tickets

Expand Down Expand Up @@ -153,6 +153,11 @@ webhook:
backends:
claude:
model: sonnet
codex:
model: gpt-5.5
args:
- --sandbox
- danger-full-access
anthropic:
api_key: $ANTHROPIC_API_KEY
model: claude-sonnet-4-20250514
Expand Down
10 changes: 5 additions & 5 deletions skills/setup-test-board.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,24 @@ Help me set up a test board on Fizzy to dogfood fizzy-popper. Walk me through it

Here's what we need:

1. **Create a board** (or pick an existing one). Use `fizzy board list` to show what's available, then `fizzy board create --title "Agent Playground"` if we need a new one.
1. **Create a board** (or pick an existing one). Use `fizzy board list` to show what's available, then `fizzy board create --name "Agent Playground"` if we need a new one.

2. **Create columns**. We need at least two — one for the agent to watch and one for cards to land in after processing. For example:
- `fizzy column create --board BOARD_ID --title "Triage"`
- `fizzy column create --board BOARD_ID --title "Done"`
- `fizzy column create --board BOARD_ID --name "Triage"`
- `fizzy column create --board BOARD_ID --name "Done"`

3. **Create a golden ticket** — this is the card that tells fizzy-popper what to do in a column. It needs:
- A title like "Triage Agent"
- A description with the agent's instructions (the prompt)
- Tags: `#agent-instructions` (required), a backend tag like `#claude`, and a completion tag like `#move-to-done`
- Tags: `#agent-instructions` (required), a backend tag like `#codex` or `#claude`, and a completion tag like `#move-to-done`
- Placed in the column it configures
- Optionally, steps (checklist items) the agent should follow

```bash
fizzy card create --board BOARD_ID --title "Triage Agent" \
--description "Summarize the card and propose a plan of action."
fizzy card tag CARD_NUMBER --tag agent-instructions
fizzy card tag CARD_NUMBER --tag claude
fizzy card tag CARD_NUMBER --tag codex
fizzy card tag CARD_NUMBER --tag move-to-done
fizzy card column CARD_NUMBER --column COLUMN_ID
fizzy step create CARD_NUMBER --content "Acknowledge the request"
Expand Down
85 changes: 72 additions & 13 deletions src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,58 @@ export interface AgentBackend {
execute(prompt: string, options: BackendOptions): Promise<AgentResult>
}

export function parseCodexOutput(raw: string): string {
try {
const parsed = JSON.parse(raw)
if (typeof parsed.output === "string") return parsed.output
if (typeof parsed.result === "string") return parsed.result
return raw
} catch { /* not a single JSON object */ }

let finalMessage: string | null = null
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed.startsWith("{")) continue

try {
const event = JSON.parse(trimmed)
if (
event.type === "item.completed" &&
event.item?.type === "agent_message" &&
typeof event.item.text === "string"
) {
finalMessage = event.item.text
}
} catch { /* ignore non-JSON log lines */ }
}

return finalMessage ?? raw
}

export function formatBackendError(err: unknown): string {
if (err && typeof err === "object") {
const data = err as Record<string, unknown>
const concise = data.shortMessage ?? data.originalMessage
if (typeof concise === "string" && concise.trim()) return concise.trim()

const command = typeof data.command === "string" ? data.command : ""
const timedOut = data.timedOut === true
const durationMs = typeof data.durationMs === "number" ? data.durationMs : undefined
if (timedOut && command) {
const duration = durationMs === undefined ? "" : ` after ${durationMs} milliseconds`
return `Command timed out${duration}: ${command}`
}

const exitCode = data.exitCode
if (command && (typeof exitCode === "number" || typeof exitCode === "string")) {
return `Command failed with exit code ${exitCode}: ${command}`
}
}

const message = err instanceof Error ? err.message : String(err)
return message.split(/\r?\n/)[0].trim()
}

// ── Prompt builder ──

export function buildPrompt(
Expand Down Expand Up @@ -132,7 +184,7 @@ class ClaudeBackend implements AgentBackend {
metadata: { duration_ms: Date.now() - start },
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
const message = formatBackendError(err)
return { output: "", success: false, error: message }
}
}
Expand All @@ -142,9 +194,11 @@ class ClaudeBackend implements AgentBackend {
class CodexBackend implements AgentBackend {
name = "codex"
private model: string
private args: string[]

constructor(config: Config) {
this.model = config.backends?.codex?.model ?? "codex-mini"
this.args = config.backends?.codex?.args ?? []
}

async execute(prompt: string, options: BackendOptions): Promise<AgentResult> {
Expand All @@ -153,21 +207,26 @@ class CodexBackend implements AgentBackend {
try {
const result = await execa(
"codex",
["exec", "--model", model, "--json", "--ephemeral", prompt],
{ timeout: options.timeout, cancelSignal: options.signal },
[
"exec",
...this.args,
"--model",
model,
"--json",
"--ephemeral",
"--cd",
process.cwd(),
],
Comment thread
joshyorko marked this conversation as resolved.
{ input: prompt, timeout: options.timeout, cancelSignal: options.signal },
)
let output = result.stdout
try {
const parsed = JSON.parse(output)
output = parsed.output ?? parsed.result ?? output
} catch { /* not JSON, use raw stdout */ }
const output = parseCodexOutput(result.stdout)
return {
output,
success: true,
metadata: { duration_ms: Date.now() - start },
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
const message = formatBackendError(err)
return { output: "", success: false, error: message }
}
}
Expand Down Expand Up @@ -197,7 +256,7 @@ class OpenCodeBackend implements AgentBackend {
metadata: { duration_ms: Date.now() - start },
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
const message = formatBackendError(err)
return { output: "", success: false, error: message }
}
}
Expand Down Expand Up @@ -238,7 +297,7 @@ class AnthropicBackend implements AgentBackend {
},
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
const message = formatBackendError(err)
return { output: "", success: false, error: message }
}
}
Expand Down Expand Up @@ -276,7 +335,7 @@ class OpenAIBackend implements AgentBackend {
},
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
const message = formatBackendError(err)
return { output: "", success: false, error: message }
}
}
Expand Down Expand Up @@ -313,7 +372,7 @@ class CommandBackend implements AgentBackend {
metadata: { duration_ms: Date.now() - start },
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
const message = formatBackendError(err)
return { output: "", success: false, error: message }
} finally {
try { rmSync(tempDir, { recursive: true }) } catch { /* ignore */ }
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const BackendConfigSchema = z.object({
}).optional(),
codex: z.object({
model: z.string().default("codex-mini"),
args: z.array(z.string()).default([]),
}).optional(),
opencode: z.object({}).optional(),
anthropic: z.object({
Expand Down
45 changes: 42 additions & 3 deletions src/setup.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import * as p from "@clack/prompts"
import chalk from "chalk"
import { execFileSync } from "node:child_process"
import { saveConfig } from "./config.js"
import { FizzyClient, type FizzyBoard } from "./fizzy.js"
import { detectBackends } from "./agent.js"

const DEFAULT_FIZZY_API_URL = "https://app.fizzy.do"

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

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

DEFAULT_FIZZY_API_URL duplicates the hosted default already embedded in the config schema (src/config.ts defaults fizzy.api_url to the same string). To avoid these drifting over time, consider centralizing this default in a single exported constant and reusing it in both places.

Suggested change
const DEFAULT_FIZZY_API_URL = "https://app.fizzy.do"
export const DEFAULT_FIZZY_API_URL = "https://app.fizzy.do"

Copilot uses AI. Check for mistakes.

export async function runSetup(): Promise<void> {
p.intro(chalk.bold("fizzy-popper") + " — AI agents for your Fizzy boards")
const apiUrl = resolveFizzyApiUrl()
p.log.info(`Fizzy API: ${apiUrl}`)

// API token
const token = await p.text({
Expand All @@ -19,7 +24,7 @@ export async function runSetup(): Promise<void> {

// Validate token and get accounts
const tempClient = new FizzyClient({
fizzy: { token: token as string, account: "", api_url: "https://app.fizzy.do" },
fizzy: { token: token as string, account: "", api_url: apiUrl },
} as any)

let identity: Awaited<ReturnType<typeof tempClient.getIdentity>>
Expand Down Expand Up @@ -55,7 +60,7 @@ export async function runSetup(): Promise<void> {

// Fetch boards
const client = new FizzyClient({
fizzy: { token: token as string, account: accountSlug, api_url: "https://app.fizzy.do" },
fizzy: { token: token as string, account: accountSlug, api_url: apiUrl },
} as any)

let boards: FizzyBoard[]
Expand Down Expand Up @@ -108,7 +113,7 @@ export async function runSetup(): Promise<void> {
fizzy: {
token: token as string,
account: accountSlug,
api_url: "https://app.fizzy.do",
api_url: apiUrl,
},
boards: selectedBoards as string[],
agent: {
Expand Down Expand Up @@ -144,3 +149,37 @@ function cancel(): void {
p.cancel("Setup cancelled.")
process.exit(0)
}

export function resolveFizzyApiUrl(): string {
const envUrl = normalizeFizzyApiUrl(process.env.FIZZY_API_URL)
if (envUrl) return envUrl

try {
const stdout = execFileSync("fizzy", ["config", "show", "--json"], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
})
const parsed = JSON.parse(stdout) as { data?: { api_url?: unknown } }
const apiUrl = normalizeFizzyApiUrl(parsed.data?.api_url)
if (apiUrl) return apiUrl
} catch {
// If the Fizzy CLI is unavailable or unauthenticated, fall back to hosted Fizzy.
}

return DEFAULT_FIZZY_API_URL
}

function normalizeFizzyApiUrl(raw: unknown): string | null {
if (typeof raw !== "string") return null
const apiUrl = raw.trim().replace(/\/+$/, "")
if (!apiUrl) return null

try {
const parsed = new URL(apiUrl)
if (parsed.protocol === "http:" || parsed.protocol === "https:") return apiUrl
} catch {
// Ignore invalid URLs and fall back to the next configured source.
}

return null
}
Loading
Loading