Initial agentchat server, skill, and deployment setup
Build and Push agentchat Image / quality (push) Successful in 28s
Build and Push agentchat Image / build-image (push) Successful in 23s

Bun + bun:sqlite message hub for agents on different machines, with a
self-served Claude Code skill and installer, podman compose files, and
Gitea CI that builds and pushes the container image.
This commit is contained in:
Gabriel Brown
2026-08-13 09:17:26 -04:00
commit 377a6472a9
16 changed files with 643 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
import { Database } from "bun:sqlite";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
// One message log. `to` is null for broadcasts; agents are recorded whenever
// they send or check in, so /api/agents reflects who has actually shown up.
export type Message = {
id: number;
ts: string;
from: string;
to: string | null;
body: string;
};
export type Agent = {
name: string;
machine: string | null;
last_seen: string;
};
const dbPath = process.env.AGENTCHAT_DB ?? "data/agentchat.db";
if (dbPath !== ":memory:") mkdirSync(dirname(dbPath), { recursive: true });
const db = new Database(dbPath, { create: true });
db.run("PRAGMA journal_mode = WAL");
db.run(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
sender TEXT NOT NULL,
recipient TEXT,
body TEXT NOT NULL
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS agents (
name TEXT PRIMARY KEY,
machine TEXT,
last_seen TEXT NOT NULL
)
`);
const SELECT_MESSAGE = `SELECT id, ts, sender AS "from", recipient AS "to", body FROM messages`;
export function addMessage(input: { from: string; to: string | null; body: string }): Message {
const message = db
.query(
`INSERT INTO messages (sender, recipient, body) VALUES ($from, $to, $body)
RETURNING id, ts, sender AS "from", recipient AS "to", body`,
)
.get({ $from: input.from, $to: input.to, $body: input.body }) as Message;
touchAgent(input.from);
return message;
}
// Without `for`: the full log (for the web view). With `for`: that agent's
// inbox — messages addressed to it or broadcast, excluding its own.
// Returns the newest `limit` messages after `since`, oldest first.
export function listMessages(opts: { for?: string; since?: number; limit?: number } = {}): Message[] {
const since = opts.since && Number.isFinite(opts.since) ? opts.since : 0;
const limit = opts.limit && Number.isFinite(opts.limit) ? Math.min(Math.max(Math.floor(opts.limit), 1), 500) : 50;
const rows = opts.for
? (db
.query(
`${SELECT_MESSAGE}
WHERE id > $since AND sender <> $name AND (recipient IS NULL OR recipient = $name)
ORDER BY id DESC LIMIT $limit`,
)
.all({ $since: since, $name: opts.for, $limit: limit }) as Message[])
: (db
.query(`${SELECT_MESSAGE} WHERE id > $since ORDER BY id DESC LIMIT $limit`)
.all({ $since: since, $limit: limit }) as Message[]);
return rows.reverse();
}
export function touchAgent(name: string, machine?: string | null): void {
db.query(
`INSERT INTO agents (name, machine, last_seen)
VALUES ($name, $machine, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
ON CONFLICT(name) DO UPDATE SET
last_seen = excluded.last_seen,
machine = COALESCE(excluded.machine, agents.machine)`,
).run({ $name: name, $machine: machine ?? null });
}
export function listAgents(): Agent[] {
return db.query(`SELECT name, machine, last_seen FROM agents ORDER BY name`).all() as Agent[];
}
+169
View File
@@ -0,0 +1,169 @@
import { listAgents, listMessages, addMessage, touchAgent, type Message } from "./db";
const MAX_WAIT_SECONDS = 60;
const POLL_INTERVAL_MS = 1000;
const skillFile = Bun.file(new URL("../skill/SKILL.md", import.meta.url));
const installFile = Bun.file(new URL("../install.sh", import.meta.url));
function json(data: unknown, status = 200): Response {
return Response.json(data, { status });
}
// Base URL as seen by the client, honoring reverse-proxy headers so the
// served skill/install script point at the public domain.
function baseUrl(req: Request): string {
const url = new URL(req.url);
const proto = req.headers.get("x-forwarded-proto") ?? url.protocol.replace(":", "");
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? url.host;
return `${proto}://${host}`;
}
function intParam(url: URL, name: string): number | undefined {
const raw = url.searchParams.get(name);
if (raw === null) return undefined;
const value = Number(raw);
return Number.isFinite(value) ? value : undefined;
}
async function handleSendMessage(req: Request): Promise<Response> {
const payload = (await req.json().catch(() => null)) as Record<string, unknown> | null;
if (payload === null) return json({ error: "invalid JSON body" }, 400);
const from = typeof payload.from === "string" ? payload.from.trim() : "";
const body = typeof payload.body === "string" ? payload.body.trim() : "";
const to = typeof payload.to === "string" && payload.to.trim() !== "" ? payload.to.trim() : null;
if (!from || !body) return json({ error: "`from` and `body` are required non-empty strings" }, 400);
return json(addMessage({ from, to, body }), 201);
}
async function handleListMessages(url: URL): Promise<Response> {
const forName = url.searchParams.get("for")?.trim() || undefined;
const opts = { for: forName, since: intParam(url, "since"), limit: intParam(url, "limit") };
if (forName) touchAgent(forName);
const wait = Math.min(intParam(url, "wait") ?? 0, MAX_WAIT_SECONDS);
if (!forName || wait <= 0) return json(listMessages(opts));
// Long poll: return as soon as something arrives, or empty on timeout.
const deadline = Date.now() + wait * 1000;
while (true) {
const messages = listMessages(opts);
if (messages.length > 0 || Date.now() >= deadline) return json(messages);
await Bun.sleep(POLL_INTERVAL_MS);
}
}
async function serveTemplated(file: Bun.BunFile, base: string, contentType: string): Promise<Response> {
const text = (await file.text()).replaceAll("{{BASE_URL}}", base);
return new Response(text, { headers: { "content-type": contentType } });
}
export async function handle(req: Request): Promise<Response> {
const url = new URL(req.url);
const { pathname } = url;
if (pathname === "/api/messages") {
if (req.method === "POST") return handleSendMessage(req);
if (req.method === "GET") return handleListMessages(url);
return json({ error: "method not allowed" }, 405);
}
if (pathname === "/api/agents") {
if (req.method === "GET") return json(listAgents());
if (req.method === "POST") {
const payload = (await req.json().catch(() => null)) as Record<string, unknown> | null;
const name = typeof payload?.name === "string" ? payload.name.trim() : "";
if (!name) return json({ error: "`name` is a required non-empty string" }, 400);
touchAgent(name, typeof payload?.machine === "string" ? payload.machine.trim() : null);
return json(listAgents().find((agent) => agent.name === name));
}
return json({ error: "method not allowed" }, 405);
}
if (req.method === "GET") {
if (pathname === "/healthz") return new Response("ok");
if (pathname === "/skill.md") return serveTemplated(skillFile, baseUrl(req), "text/markdown; charset=utf-8");
if (pathname === "/install.sh") return serveTemplated(installFile, baseUrl(req), "text/x-shellscript; charset=utf-8");
if (pathname === "/") {
return new Response(renderHome(baseUrl(req)), { headers: { "content-type": "text/html; charset=utf-8" } });
}
}
return json({ error: "not found" }, 404);
}
function escapeHtml(text: string): string {
return text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function renderMessage(message: Message): string {
const to = message.to ? escapeHtml(message.to) : "everyone";
return `<li>
<span class="meta">${escapeHtml(message.ts)} · <b>${escapeHtml(message.from)}</b> → ${to}</span>
<p>${escapeHtml(message.body)}</p>
</li>`;
}
function renderHome(base: string): string {
const agents = listAgents();
const messages = listMessages({ limit: 50 });
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="15">
<title>agentchat</title>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, sans-serif; max-width: 42rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
code, pre { font-family: ui-monospace, monospace; background: color-mix(in srgb, currentColor 8%, transparent); border-radius: 4px; }
code { padding: 0.1rem 0.3rem; }
pre { padding: 0.6rem 0.8rem; overflow-x: auto; }
ul { list-style: none; padding: 0; }
li { margin-bottom: 0.75rem; }
li p { margin: 0.1rem 0 0; white-space: pre-wrap; }
.meta { font-size: 0.8rem; opacity: 0.65; }
h2 { margin-top: 2rem; font-size: 1.1rem; }
</style>
</head>
<body>
<h1>agentchat</h1>
<p>A shared message hub for agents on different machines.</p>
<h2>Setup on a new machine</h2>
<p>Install the Claude Code skill:</p>
<pre>curl -fsSL ${escapeHtml(base)}/install.sh | sh</pre>
<p>Agents identify as <code>$AGENTCHAT_NAME</code>, falling back to the machine's hostname.
See <a href="/skill.md">skill.md</a> for the API the skill teaches.</p>
<h2>Agents (${agents.length})</h2>
<ul>${agents
.map(
(agent) =>
`<li><b>${escapeHtml(agent.name)}</b> <span class="meta">last seen ${escapeHtml(agent.last_seen)}</span></li>`,
)
.join("")}</ul>
<h2>Recent messages</h2>
<ul>${messages.map(renderMessage).join("")}</ul>
</body>
</html>`;
}
if (import.meta.main) {
// Exit promptly on container stop instead of waiting out the SIGKILL grace period.
process.on("SIGTERM", () => process.exit(0));
process.on("SIGINT", () => process.exit(0));
const port = Number(process.env.PORT ?? 8080);
// idleTimeout must exceed the long-poll window or Bun kills waiting requests.
Bun.serve({ port, idleTimeout: MAX_WAIT_SECONDS + 30, fetch: handle });
console.log(`agentchat listening on :${port}`);
}