SpaceChannel bridge: mirrors the session to the SpaceNotes app and delivers phone messages as typed prompts

<img src="assets/android/mipmap-xxxhdpi/spacenotes2.png" width="128" alt="SpaceNotes Logo" />
<h1 align="center">SpaceNotes</h1>
Yet another note-taking system... 🙄
But notes, files and passwords synced across all your devices in real time. No cost. No Obsidian. No cloud. No storage limits.
Your vault is plain files on your own filesystem: markdown notes, media, PDFs and a pass-compatible password store. Portable, no lock-in, no subscription, nothing to migrate off if you ever want to walk away. A built-in MCP server lets AI assistants like Claude Code and Cursor read and write it.
Contributions welcome.

<img src="assets/screenshots/mobile-notes.png" width="45%" alt="Mobile Notes View" />
| Feature | SpaceNotes | Obsidian Sync | Notion | Notesnook | Basic Memory |
|---|---|---|---|---|---|
| Self-hosted | Yes | No | No | Yes | No |
| Real-time sync | Yes | Yes | Yes | Yes | Yes |
| Mobile + Web | Yes | Mobile only | Yes | Yes | Web only |
| AI integration | MCP | None | Built-in | None | MCP |
| Plain files | Yes | Yes | No | Partial | Yes |
| Data ownership | Full | Partial | None | Full | Partial |
| Cost | Free | $8/mo | Free/$10/mo | Free/$5/mo | Paid |
Requirements:
Current limitations:
One Docker container on your server runs everything:
/files, /thumbnails and /uploads to the daemon.The Flutter client (iOS, Android, macOS, Windows, Linux, web) subscribes to SpacetimeDB for notes and talks to the daemon over HTTP for file bytes.
The vault on disk is the ground truth. The database can be wiped and rebuilt from it; a note's identity (its UUID) is kept in the daemon's own journal, so vault files stay byte-for-byte what you wrote, with nothing injected into them.
/files/ (downloads, whole-file uploads), /uploads (resumable uploads) and /thumbnails/./mcp.All ports are configurable via docker-compose.yml.
The vault isn't only markdown. Images (jpg, png, gif, webp, heic), audio (mp3, wav, m4a, aac, flac, ogg), video (mp4, mov, m4v, webm), PDFs and .gpg files are all first-class; anything else is ignored.
Range. A download that slows to a crawl reconnects itself, and reopening a file cancels any stale transfer before resuming. Downloads are verified by size and SHA-256 before they count as complete.POST/HEAD/PATCH /uploads). An upload survives the app being backgrounded or killed and continues on next launch from the server's offset.The web client is notes-only: downloading and uploading binary files needs a native app.
SpaceNotes can hold a pass-compatible password store: GPG-encrypted .gpg files under .password-store/ in the vault, synced like any other file.
Notes and files:
Agent chat:
Mobile (iOS/Android):
Desktop (macOS/Windows/Linux/Web):
bash curl -O https://raw.githubusercontent.com/mikaelwills/SpaceNotes/master/docker-compose.yml `` SPACENOTES_FILES_HOST` is required: the MCP server hands URLs on this host to AI assistants for file transfers, so it must be the address your other machines use (e.g. the server's Tailscale IP). The MCP server won't start without it.bash docker-compose up -d `` Docker pulls the pre-built image. First run takes a minute to download.bash docker logs spacenotes `` You should see "Watcher started on /vault" when ready.http://<your-server-ip>:5051<your-server-ip> in Settings → serverhttp://<your-server-ip>:5052/mcpdocker-compose pull && docker-compose up -d
Your notes are safe - they live on your filesystem, not in the database. Keep the named volumes: spacetime-config holds the database owner identity (lose it and the module can't be republished), and spacenotes-daemon-data holds the note identity journal.
claude mcp add spacenotes-mcp --type http --url "http://<your-server-ip>:5052/mcp" --scope user
Or add to ~/.claude.json:
{
"mcpServers": {
"spacenotes-mcp": {
"type": "http",
"url": "http://<your-server-ip>:5052/mcp"
}
}
}
Find and read:
search_files - search by title, path or contentsearch_files_content - search and return excerpts around matchesget_file / get_files - full content of one or several files, by id or path (optionally one heading or a line range)list_folder - immediate subfolders and files of a foldervault_index - every folder path, plus files under chosen foldersget_backlinks / get_outbound_links - link graph for a noteWrite:
create_file - create a noteedit_file - find-and-replace, one or many edits in one commitregex_replace, replace_across_files - pattern replace in one note or across manyappend_to_file / prepend_to_fileOrganise:
move_file, move_files_to_folderdelete_file, delete_filescreate_folder, move_folder, delete_folder, empty_folderBinary files:
upload_file / download_file - return a plain HTTP URL; the assistant moves the bytes itself (curl -T / curl -o), so large files never pass through a tool callEnvironment variables (set in docker-compose.yml):
SPACENOTES_FILES_HOST - required. Externally reachable base URL of port 5051, used in upload_file/download_file URLsDATA_DIR - daemon state (note identity journal); point at the /data volumeVAULT_PATH - path to the vault inside the container (default: /vault)SPACETIME_HOST - SpacetimeDB URL, internal (default: http://127.0.0.1:3000)SPACETIME_DB - database name (default: spacenotes)GPL-3.0 - This project is free software. Any derivative work must also be open source under the same license.
hooks/register.ts 386 lines1import type { Register } from 'claude-code'
2
3const BRIDGE = 'space-channel'
4const POLL_TIMEOUT_MS = 20_000
5const PERMISSION_TIMEOUT_MS = 9.5 * 60_000
6const QUESTION_TIMEOUT_MS = 10 * 60_000
7
8const INTERNAL_TOOLS = new Set([
9 'push_message',
10 'edit_message',
11 'push_status',
12 'push_tool_event',
13 'push_context_usage',
14 'next_message',
15 'request_permission',
16 'poll_permission',
17 'request_question',
18 'poll_question',
19])
20
21type Inbound = { id: string; text: string; source: string; sender: string; imagePaths?: string[] }
22
23let server = ''
24let turnRunning = false
25let lastPromptFromPhone = false
26let currentTurnId: string | undefined
27let draining = false
28let toolsRunning = 0
29let heldProgress = ''
30let herdrPane: string | undefined
31let herdrBin: string | undefined
32const deliveries: Inbound[] = []
33
34function internalToolName(tool: string): string | undefined {
35 const m = /^mcp__.+__([a-z_]+)$/.exec(tool)
36 if (!m) return undefined
37 return INTERNAL_TOOLS.has(m[1]!) ? m[1] : undefined
38}
39
40function toolArguments(e: Record<string, unknown>): Record<string, unknown> {
41 const { tool: _tool, tool_use_id: _id, consent: _consent, agentId: _agent, ...rest } = e
42 return rest
43}
44
45async function bridge($: any, tool: string, args: Record<string, unknown>): Promise<any> {
46 if (!server) throw new Error('bridge not connected')
47 const r = await $.mcp.call(server, tool, args)
48 const text = r.content.find((b: any) => b.type === 'text')?.text ?? ''
49 if (r.isError) throw new Error(text || `${tool} failed`)
50 try {
51 return JSON.parse(text)
52 } catch {
53 return text
54 }
55}
56
57const BRIDGE_TIMEOUT_MS = 3_000
58const LONG_POLLS = new Set(['next_message', 'poll_question', 'poll_permission'])
59
60function withTimeout<T>(call: Promise<T>, ms: number, what: string): Promise<T> {
61 return new Promise((resolve, reject) => {
62 const timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms}ms`)), ms)
63 call.then(
64 (v) => {
65 clearTimeout(timer)
66 resolve(v)
67 },
68 (e) => {
69 clearTimeout(timer)
70 reject(e)
71 },
72 )
73 })
74}
75
76async function tryBridge($: any, tool: string, args: Record<string, unknown>): Promise<any> {
77 try {
78 const call = bridge($, tool, args)
79 return await (LONG_POLLS.has(tool) ? call : withTimeout(call, BRIDGE_TIMEOUT_MS, tool))
80 } catch (err) {
81 $.ui.log(`${tool} failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
82 return undefined
83 }
84}
85
86function touchActivity(fromPhone: boolean) {
87 lastPromptFromPhone = fromPhone
88}
89
90async function deliver($: any, m: Inbound) {
91 touchActivity(true)
92 const fromAgent = m.source.startsWith('agent:')
93 let text = m.text.trim()
94 const images = m.imagePaths ?? []
95 if (!text && images.length > 0) text = images.length === 1 ? '(image)' : '(images)'
96 if (images.length === 1) text += `\n\n(image attached at ${images[0]} — read it with the Read tool)`
97 if (images.length > 1) {
98 text += `\n\n(${images.length} images attached — read each with the Read tool:\n${images.map(p => `- ${p}`).join('\n')})`
99 }
100 if (fromAgent) {
101 text += `\n\n(a2a message from agent '${m.sender}' — to answer THEM use send_to_agent('${m.sender}'); plain text goes to your own human's chat, not to them)`
102 await $.prompt.submit({ text })
103 return
104 }
105 if (images.length === 0 && (await runSlashCommand($, text))) return
106 await $.prompt.submit({ text, asUser: true })
107}
108
109async function runSlashCommand($: any, text: string): Promise<boolean> {
110 const match = /^\/([\w:.-]+)(?:\s+([\s\S]*))?$/.exec(text)
111 if (!match) return false
112 while (turnRunning) await $.clock.sleep(500)
113 let result: any
114 try {
115 result = await $.command.run({ command: match[1], args: match[2] ?? '' })
116 } catch (err) {
117 $.ui.log(`/${match[1]} not run as a command: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
118 return false
119 }
120 const output = typeof result?.text === 'string' ? result.text.trim() : ''
121 if (output) await tryBridge($, 'push_message', { role: 'assistant', text: output, source: 'notice' })
122 return true
123}
124
125function flushProgress($: any) {
126 const text = heldProgress
127 heldProgress = ''
128 if (!text) return
129 void tryBridge($, 'push_message', { role: 'assistant', text, source: 'progress' })
130}
131
132function responseText(content: any[]): string {
133 return content
134 .filter((b) => b?.type === 'text' && typeof b.text === 'string')
135 .map((b) => b.text.trim())
136 .filter(Boolean)
137 .join('\n\n')
138}
139
140async function drainDeliveries($: any) {
141 if (draining) return
142 draining = true
143 try {
144 while (deliveries.length > 0) {
145 const m = deliveries.shift()!
146 await deliver($, m)
147 }
148 } finally {
149 draining = false
150 }
151}
152
153async function reportTurnEnd($: any, reason: string, answer: string) {
154 if (reason === 'aborted') {
155 await tryBridge($, 'push_message', { role: 'assistant', text: 'Stopped', source: 'notice' })
156 } else if (reason === 'answer' && answer) {
157 await tryBridge($, 'push_message', { role: 'assistant', text: answer, source: 'mcp' })
158 } else if (reason === 'error' || reason === 'refusal') {
159 await tryBridge($, 'push_message', {
160 role: 'assistant',
161 text: `⚠️ ${answer || `turn ended: ${reason}`}`,
162 source: 'error',
163 })
164 }
165 await tryBridge($, 'push_status', { state: 'idle' })
166}
167
168async function stopTurn($: any) {
169 if (!turnRunning || !currentTurnId) {
170 $.ui.log('stop requested from SpaceNotes with no turn running', { to: 'debug' })
171 await tryBridge($, 'push_status', { state: 'idle' })
172 return
173 }
174 try {
175 await $.turn.abort({ turnId: currentTurnId })
176 } catch (err) {
177 $.ui.log(`stop failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
178 }
179}
180
181async function backgroundTool($: any) {
182 if (toolsRunning === 0) {
183 $.ui.log('background requested from SpaceNotes with no tool running', { to: 'debug' })
184 return
185 }
186 if (!herdrPane || !herdrBin) {
187 await tryBridge($, 'push_message', { role: 'assistant', text: '⚠️ background needs the session to run in a herdr pane', source: 'error' })
188 return
189 }
190 try {
191 await $.process.run({ argv: [herdrBin, 'pane', 'send-keys', herdrPane, 'ctrl+b'] })
192 } catch (err) {
193 $.ui.log(`background failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
194 }
195}
196
197async function inboundLoop($: any) {
198 while (true) {
199 const res = await tryBridge($, 'next_message', { timeoutMs: POLL_TIMEOUT_MS })
200 if (res === undefined) {
201 await $.clock.sleep(2000)
202 continue
203 }
204 const m: Inbound | undefined = res.message
205 if (!m) continue
206 if (m.source === 'control') {
207 if (m.text === 'stop') await stopTurn($)
208 if (m.text === 'background') await backgroundTool($)
209 continue
210 }
211 deliveries.push(m)
212 void drainDeliveries($)
213 }
214}
215
216async function relayQuestion($: any, questions: unknown): Promise<Record<string, string> | undefined> {
217 if (!Array.isArray(questions) || questions.length === 0) return undefined
218 const shaped = questions.map((q: any) => ({
219 question: String(q?.question ?? ''),
220 header: String(q?.header ?? ''),
221 options: Array.isArray(q?.options)
222 ? q.options.map((o: any) => String(o?.label ?? '')).filter((l: string) => l.length > 0)
223 : [],
224 multiSelect: q?.multiSelect === true,
225 }))
226 let requested: any
227 try {
228 requested = await withTimeout(bridge($, 'request_question', { questions: shaped }), BRIDGE_TIMEOUT_MS, 'request_question')
229 } catch (err) {
230 void tryBridge($, 'push_message', {
231 role: 'assistant',
232 text: `⚠️ request_question failed: ${err instanceof Error ? err.message : String(err)}`,
233 source: 'error',
234 })
235 return undefined
236 }
237 const ids: string[] = requested?.ids ?? []
238 if (ids.length !== shaped.length) return undefined
239
240 const answers: Record<string, string> = {}
241 const deadline = Date.now() + QUESTION_TIMEOUT_MS
242 for (let i = 0; i < ids.length; i++) {
243 let response: string | null | undefined
244 while (Date.now() < deadline) {
245 const polled = await tryBridge($, 'poll_question', { id: ids[i], timeoutMs: POLL_TIMEOUT_MS })
246 if (polled === undefined) return undefined
247 if (polled.status === 'answered') {
248 response = polled.response
249 break
250 }
251 }
252 if (response === undefined || response === null) return undefined
253 let selected: string
254 try {
255 const parsed = JSON.parse(response)
256 selected = Array.isArray(parsed) ? parsed.join(', ') : String(parsed)
257 } catch {
258 selected = response
259 }
260 answers[shaped[i]!.question] = selected
261 }
262 return answers
263}
264
265export const register: Register = on => {
266 on('session.start', async ($, e, next) => {
267 const started = await next(e)
268 const connected = await $.mcp.connect(BRIDGE)
269 if (!connected.isConnected) {
270 $.ui.log(`bridge not connected: ${connected.reason ?? ''} ${connected.message ?? ''}`.trim())
271 return started
272 }
273 server = connected.server
274 herdrPane = (await $.env.get('HERDR_PANE_ID')) || undefined
275 const home = await $.env.get('HOME')
276 if (home) herdrBin = `${home}/.local/bin/herdr`
277 void inboundLoop($)
278 return started
279 })
280
281 on('prompt.submit', async ($, e, next) => {
282 const kind = e.origin?.kind
283 if (kind === 'composer' || kind === 'bridge') {
284 touchActivity(false)
285 void tryBridge($, 'push_message', { role: 'user', text: e.text, source: 'terminal' })
286 }
287 void tryBridge($, 'push_status', { state: 'thinking' })
288 return next(e)
289 })
290
291 on('turn.start', async ($, e, next) => {
292 turnRunning = true
293 heldProgress = ''
294 currentTurnId = e.turnId
295 void tryBridge($, 'push_status', { state: 'thinking' })
296 return next(e)
297 })
298
299 on('turn.complete', async ($, e, next) => {
300 if (e.agentId) return next(e)
301 turnRunning = false
302 heldProgress = ''
303 currentTurnId = undefined
304 const answer = e.answer.trim()
305 const reason = e.reason
306 $.clock.after(0, () => reportTurnEnd($, reason, answer))
307 return next(e)
308 })
309
310 on('session.append', { door: 'response' }, async ($, e, next) => {
311 const stored = await next(e)
312 if (e.agentId || e.origin?.kind !== 'model' || e.message?.role !== 'assistant') return stored
313 const content = Array.isArray(e.message.content) ? e.message.content : []
314 const text = responseText(content)
315 if (!text) return stored
316 flushProgress($)
317 if (content.some((b: any) => b?.type === 'tool_use')) {
318 void tryBridge($, 'push_message', { role: 'assistant', text, source: 'progress' })
319 } else {
320 heldProgress = text
321 }
322 return stored
323 })
324
325 on('tool.call', async ($, e, next) => {
326 const fromModel = next.origin.plugin === 'engine'
327 if (!fromModel) return next(e)
328 if (!e.agentId) flushProgress($)
329 const internal = internalToolName(e.tool)
330 if (internal) {
331 return { deny: `${internal} is internal to the space-channel bridge; the session calls it, not you.` }
332 }
333 const input = toolArguments(e as Record<string, unknown>)
334 if (e.tool === 'AskUserQuestion') {
335 const skip = !server ? 'bridge not connected' : !lastPromptFromPhone ? 'last prompt was not from the phone' : ''
336 const answers = skip ? undefined : await relayQuestion($, (input as any).questions)
337 if (answers) return { result: { questions: (input as any).questions, answers } } as any
338 void tryBridge($, 'push_message', {
339 role: 'assistant',
340 text: `⚠️ question not relayed to the app: ${skip || 'relay failed'}; it is waiting in the terminal`,
341 source: 'error',
342 })
343 }
344 void tryBridge($, 'push_tool_event', { tool: e.tool, detail: JSON.stringify({ tool: e.tool, input }) })
345 void tryBridge($, 'push_status', { state: 'tool_use' })
346 toolsRunning++
347 try {
348 return await next(e)
349 } finally {
350 toolsRunning--
351 void tryBridge($, 'push_status', { state: 'thinking' })
352 }
353 })
354
355 on('tool.check', async ($, e, next) => {
356 if (internalToolName(e.tool)) return { decision: 'allow', reason: 'space-channel bridge call' }
357 if (e.tool === 'AskUserQuestion' && lastPromptFromPhone) return { decision: 'allow', reason: 'asked through SpaceNotes' }
358 const verdict = await next(e)
359 if (verdict.decision !== 'ask' || !lastPromptFromPhone || !server || !e.tool_use_id) return verdict
360 const id = e.tool_use_id
361 const requested = await tryBridge($, 'request_permission', {
362 id,
363 tool: e.tool,
364 input: JSON.stringify(e.input ?? {}),
365 })
366 if (requested === undefined) return verdict
367 const deadline = Date.now() + PERMISSION_TIMEOUT_MS
368 while (Date.now() < deadline) {
369 const polled = await tryBridge($, 'poll_permission', { id, timeoutMs: POLL_TIMEOUT_MS })
370 if (polled === undefined) return verdict
371 if (polled.status === 'allow') return { decision: 'allow', reason: 'approved from SpaceNotes' }
372 if (polled.status === 'deny') return { decision: 'deny', reason: 'denied from SpaceNotes' }
373 }
374 return verdict
375 })
376
377 on('session.measure', async ($, e, next) => {
378 const tokens = e.context?.tokens
379 const window = e.context?.window
380 if (e.changed.includes('context') && typeof tokens === 'number' && typeof window === 'number' && window > 0) {
381 void tryBridge($, 'push_context_usage', { used: Math.round(tokens), window: Math.round(window) })
382 }
383 return next(e)
384 })
385}
386