Web IDE: the ticket of the worktree above the prompt, and the files Claude changes opened in the IDE with one key

A self-hosted IDE in your browser, with an AI agent that runs on your models (llama.cpp, Ollama) and a kanban that takes each change from an idea to a merged branch.

1. Briefing: describe the need; the model reads the code, asks its questions, then writes the ticket. <br><br> ![]() | 2. Plan: the model writes the implementation plan and goals you can check. <br><br> ![]() |
3. Development: a branch and a worktree per ticket, in its own window; the model codes, tests, commits. <br><br> ![]() | 4. Test and merge: how to test, the goals, the diff; feedback goes back to the model; merge when it is right. <br><br> ![]() |
The full walk-through is in the user guide.

CLAUDE.md / AGENTS.md and skills, Mermaid diagrams, image / PDF / audio attachments, local dictation (Whisper in the browser), doodles.![]() | ![]() |
Linux, as a service started at boot (details):
curl -fsSL https://raw.githubusercontent.com/DrSmithFr/web-ide/main/scripts/install.sh | sh
# from your other devices, over HTTPS on your Tailscale network:
curl -fsSL https://raw.githubusercontent.com/DrSmithFr/web-ide/main/scripts/install.sh | sh -s -- --tailscale
It prints the address to open once, with the pairing token. On macOS, download the archive of the latest release and run ./web-ide-pod.
From the sources (Go 1.27+, Node.js 20.19+):
git clone https://github.com/DrSmithFr/web-ide.git && cd web-ide
make build && ./bin/web-ide-pod
Optional: git, language servers in the PATH, a llama.cpp or Ollama server for the assistant.
The pod has the rights of your user: it reads and writes files and runs commands for the page. It only answers the local machine, every request needs the pairing token, and the agent should only be connected to model servers you trust. See SECURITY.md.
CONTRIBUTING.md to get started, docs/architecture.md for the code, docs/spec.md for the intended behaviour, CHANGELOG.md for the releases.
hooks/register.tsx 185 lines1// Web IDE in Claude Code, above the prompt: links to the project Claude works in and to
2// the ticket of its worktree (at the public address of the IDE, publicUrl of the pod),
3// and the files Claude changes as buttons opening them in the IDE windows (the /open
4// endpoint of the pod, with its token). The band above the prompt is drawn by the
5// terminal and the desktop app only: the mobile app gets the same links in a pane, opened
6// when it joins the session and by /webide. /webide <file[:line]> opens any file (/ide
7// is a command of Claude Code).
8//
9// The pod: WEBIDE_URL (default http://127.0.0.1:4433), its data folder WEBIDE_DATA
10// (default ~/.web-ide), where the token is.
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, Register } from 'claude-code'
13
14import type { Ide, Ticket } from '../types'
15
16const ide = atom({ plugin: 'web-ide', key: 'ide' } as const, null)
17const files = atom({ plugin: 'web-ide', key: 'files' } as const, [])
18
19const EDITS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
20const MAX_FILES = 4
21const PANE = 'web-ide'
22// Tools moving the session to another folder: the project and the ticket change.
23const MOVES = new Set(['EnterWorktree', 'ExitWorktree'])
24
25type Pod = { url: string; token: string }
26
27/** "# Ticket #12 · Export\nStatus: In progress · …\n…\nTicket in the IDE: <url>" → the ticket. */
28export function parseTicket(markdown: string): Ticket | null {
29 const [, id, title = '', status = ''] = /^# Ticket #(\d+) · (.*)\nStatus: ([^·\n]+)/.exec(markdown) ?? []
30 const url = /^Ticket in the IDE: (\S+)$/m.exec(markdown)?.[1]
31 return id && url ? { id: Number(id), title: title.trim(), status: status.trim(), url } : null
32}
33
34/** The page of the project in an answer of kanban_list or kanban_get. */
35export function parseProject(text: string): string | null {
36 return /^Project in the IDE: (\S+)$/m.exec(text)?.[1] ?? null
37}
38
39/** "src/a.go:12" → the absolute path and the line (0 without one). */
40export function parseTarget(arg: string, cwd: string): { path: string; line: number } {
41 const [, file = '', line] = /^(.*?)(?::(\d+))?$/.exec(arg.trim()) ?? []
42 const path = file.startsWith('/') ? file : `${cwd.replace(/\/$/, '')}/${file.replace(/^\.\//, '')}`
43 return { path, line: line ? Number(line) : 0 }
44}
45
46/** The files list after a change: the newest first, without duplicates. */
47export function touched(list: string[], path: string): string[] {
48 return [path, ...list.filter((p) => p !== path)].slice(0, MAX_FILES)
49}
50
51const basename = (p: string) => p.slice(p.lastIndexOf('/') + 1)
52
53let pod: Pod | null = null
54
55async function connect($: EngineInterface): Promise<Pod | null> {
56 if (pod) return pod
57 const home = (await $.env.get('HOME')) ?? ''
58 const data = (await $.env.get('WEBIDE_DATA')) ?? `${home}/.web-ide`
59 const url = (await $.env.get('WEBIDE_URL')) ?? 'http://127.0.0.1:4433'
60 try {
61 pod = { url: url.replace(/\/$/, ''), token: (await $.fs.read(`${data}/token`)).trim() }
62 } catch {
63 return null // no pod on this machine
64 }
65 return pod
66}
67
68/** Calls a tool of the MCP endpoint of the pod: its text, null on an error. */
69async function tool($: EngineInterface, p: Pod, name: string, args: Record<string, unknown>): Promise<string | null> {
70 const res = await $.http.fetch(`${p.url}/mcp`, {
71 method: 'POST',
72 headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${p.token}` },
73 body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }),
74 })
75 const r = JSON.parse(res.text)?.result
76 return r && !r.isError ? (r.content?.[0]?.text ?? '') : null
77}
78
79/** The project Claude works in and the ticket of its worktree. */
80async function refresh($: EngineInterface) {
81 const p = await connect($)
82 if (!p) return
83 const cwd = await $.session.cwd()
84 try {
85 // In a worktree, kanban_get answers its ticket; elsewhere it fails and kanban_list
86 // still gives the project (or fails too: not a project of the IDE).
87 const text = (await tool($, p, 'kanban_get', { cwd })) ?? (await tool($, p, 'kanban_list', { cwd }))
88 const project = text ? parseProject(text) : null
89 const next: Ide | null = project ? { project, ticket: parseTicket(text ?? '') } : null
90 await update($, ide, () => next)
91 } catch {
92 await update($, ide, () => null) // the pod is not running
93 }
94}
95
96/** Opens a file in the windows of its project; answers what happened. */
97async function open($: EngineInterface, path: string, line = 0): Promise<string> {
98 const p = await connect($)
99 if (!p) return 'Web IDE: no pod token found (~/.web-ide/token).'
100 try {
101 const res = await $.http.fetch(`${p.url}/open?path=${encodeURIComponent(path)}&line=${line}`, {
102 method: 'POST',
103 headers: { Authorization: `Bearer ${p.token}` },
104 })
105 if (!res.ok) return `Web IDE: ${res.text.trim()}`
106 const r = JSON.parse(res.text)
107 return r.opened ? `Opened in the IDE: ${path}` : `No IDE window on this project: ${r.url}`
108 } catch {
109 return `Web IDE: the pod does not answer at ${p.url}.`
110 }
111}
112
113export const register: Register = (on) => {
114 on('session.start', async ($, e, next) => {
115 await $.command.register({ name: 'webide', description: 'Links to the Web IDE (project, ticket, files), or open a file in it', argumentHint: '[file[:line]]' })
116 void refresh($)
117 return next(e)
118 })
119
120 // The mobile app draws no band above the prompt: the links go in a pane there.
121 on('session.attach', { surface: 'mobile' }, async ($, e, next) => {
122 void refresh($)
123 void $.ui.open({ id: PANE, title: 'IDE' })
124 return next(e)
125 })
126
127 on('turn.complete', async ($, e, next) => {
128 void refresh($)
129 return next(e)
130 })
131
132 on('tool.call', async ($, e, next) => {
133 const ran = await next(e)
134 const path = (e as { file_path?: string }).file_path ?? (e as { notebook_path?: string }).notebook_path
135 if (EDITS.has(e.tool) && path && ran.deny === undefined) await update($, files, (list) => touched(list, path))
136 if (MOVES.has(e.tool) && ran.deny === undefined) void refresh($)
137 return ran
138 })
139
140 on('command.run', { command: 'webide' }, async ($, e) => {
141 if (e.args.trim()) {
142 const t = parseTarget(e.args, await $.session.cwd())
143 return { text: await open($, t.path, t.line) }
144 }
145 await refresh($)
146 await $.ui.open({ id: PANE, title: 'IDE' })
147 const here = await read($, ide)
148 if (!here) return { text: 'This folder is not a project of the IDE.' }
149 return { text: [`Project: ${here.project}`, ...(here.ticket ? [`Ticket #${here.ticket.id}: ${here.ticket.url}`] : [])].join('\n') }
150 })
151
152 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
153 const here = await read($, ide)
154 const list = await read($, files)
155 const { Box, Button, Link, Text } = $.ui.resolve(e)
156 const tk = here?.ticket
157 return (
158 <Box flexDirection="column">
159 {here ? <Link key="project" href={here.project} label="Project in the IDE" /> : <Text dimColor>This folder is not a project of the IDE.</Text>}
160 {tk && <Link key="ticket" href={tk.url} label={`#${tk.id} ${tk.title} · ${tk.status}`} />}
161 {list.map((path) => (
162 <Button key={path} label={`↗ ${basename(path)}`} plain onPress={() => void open($, path).then((text) => $.ui.toast(text))} />
163 ))}
164 </Box>
165 )
166 })
167
168 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
169 const here = await read($, ide)
170 const list = await read($, files)
171 if (e.props.hasSurvey || (!here && list.length === 0)) return next(e)
172 const { Box, Button, Link, Text } = $.ui.resolve(e)
173 const tk = here?.ticket
174 return (
175 <Box flexDirection="row" gap={1}>
176 {here ? <Link key="project" href={here.project} label="IDE" /> : <Text dimColor>IDE</Text>}
177 {tk && <Link key="ticket" href={tk.url} label={`#${tk.id} ${tk.title} · ${tk.status}`} />}
178 {list.map((path) => (
179 <Button key={path} label={`↗ ${basename(path)}`} plain dimColor onPress={() => void open($, path).then((text) => $.ui.toast(text))} />
180 ))}
181 </Box>
182 )
183 })
184}
185types/index.d.ts 11 lines1export type Ticket = { id: number; title: string; status: string; url: string }
2
3/** The IDE project Claude works in: its page, and the ticket of its worktree. */
4export type Ide = { project: string; ticket: Ticket | null }
5
6declare module 'claude-code' {
7 interface PluginState {
8 'web-ide': { ide: Ide | null; files: string[] }
9 }
10}
11