feat(api): agent identity + personality (makeup) file endpoints

- POST /nodes/:teamId/identity — set the on-board agent's name
  (agents.default.identity.name + reload) so it adopts the team's choice.
- GET/PUT /nodes/:teamId/personality/:file — proxy the node's
  /api/personality allowlist API (SOUL/IDENTITY/USER/AGENTS/TOOLS/
  HEARTBEAT/MEMORY), so the MAKEUP cards can read + overwrite the agent's
  makeup files and restart it. Both proxy the board gateway with the
  node's bearer token.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-27 10:55:34 +02:00
co-authored by Claude Opus 4.8
parent 0156f97b36
commit 0797923933
6 changed files with 141 additions and 0 deletions
+47
View File
@@ -362,6 +362,53 @@ export function createApp(opts: AppOptions): Express {
} }
}) })
// Set the on-board agent's name (agents.default.identity.name) so it adopts the
// name the team chose at registration. Best-effort from the client's side.
app.post('/nodes/:teamId/identity', async (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
const b = req.body ?? {}
if (typeof b.name !== 'string' || !b.name.trim()) {
return res.status(400).json({ error: 'name is required' })
}
try {
const ok = await nodes.setIdentity(String(req.params.teamId), b.name.trim())
if (!ok) return res.status(404).json({ error: 'no node registered for team' })
res.json({ ok: true })
} catch {
res.status(502).json({ error: 'could not set the agent name — is your node online?' })
}
})
// Read/write the agent's makeup ("personality") markdown files — the MAKEUP
// slide-out cards edit these. Proxies the node's /api/personality allowlist API.
const PERSONALITY_ALLOW = new Set(['SOUL.md', 'IDENTITY.md', 'USER.md', 'AGENTS.md', 'TOOLS.md', 'HEARTBEAT.md', 'MEMORY.md'])
app.get('/nodes/:teamId/personality/:file', async (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
const file = String(req.params.file)
if (!PERSONALITY_ALLOW.has(file)) return res.status(400).json({ error: 'file not editable' })
try {
const r = await nodes.getPersonality(String(req.params.teamId), file)
if (!r) return res.status(404).json({ error: 'no node registered for team' })
res.json(r)
} catch {
res.status(502).json({ error: 'could not read the file — is your node online?' })
}
})
app.put('/nodes/:teamId/personality/:file', async (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
const file = String(req.params.file)
if (!PERSONALITY_ALLOW.has(file)) return res.status(400).json({ error: 'file not editable' })
const b = req.body ?? {}
if (typeof b.content !== 'string') return res.status(400).json({ error: 'content is required' })
try {
const ok = await nodes.putPersonality(String(req.params.teamId), file, b.content)
if (!ok) return res.status(404).json({ error: 'no node registered for team' })
res.json({ ok: true })
} catch {
res.status(502).json({ error: 'could not save — is your node online?' })
}
})
// Public liveness for a team's board — the wizard/self-test polls this after // Public liveness for a team's board — the wizard/self-test polls this after
// a claim. Online reflects the bridge's live /health + SSE view. // a claim. Online reflects the bridge's live /health + SSE view.
app.get('/nodes/:teamId/status', (req, res) => { app.get('/nodes/:teamId/status', (req, res) => {
+94
View File
@@ -222,6 +222,69 @@ export async function configureTelegram(node: NodeRef, token: string): Promise<v
} }
} }
/**
* Set the on-board agent's display name (`agents.default.identity.name`) + reload,
* so the agent adopts the name the team chose. Same config-prop + watcher path as
* {@link configureTelegram} — the gateway auto-creates the key if absent.
*/
export async function configureIdentity(node: NodeRef, name: string): Promise<void> {
const auth = { authorization: `Bearer ${node.token}` }
const res = await fetch(`${node.url}/api/config/prop`, {
method: 'PUT',
headers: { ...auth, 'content-type': 'application/json' },
body: JSON.stringify({ path: 'agents.default.identity.name', value: name, comment: 'set via APESS onboarding' }),
})
if (!res.ok) throw new Error(`identity write failed (${res.status})`)
try {
await fetch(`${node.url}/admin/reload`, { method: 'POST', headers: auth })
} catch {
/* watcher will apply it */
}
}
// The agent's editable "personality" (makeup) markdown files — the gateway
// allowlist. Used to guard which files the participant can edit from the UI.
export const PERSONALITY_FILES = [
'SOUL.md',
'IDENTITY.md',
'USER.md',
'AGENTS.md',
'TOOLS.md',
'HEARTBEAT.md',
'MEMORY.md',
] as const
/** Read one of the agent's makeup files from the node (GET /api/personality/{file}). */
export async function readPersonality(node: NodeRef, file: string): Promise<{ content: string; exists: boolean }> {
const res = await fetch(`${node.url}/api/personality/${encodeURIComponent(file)}?agent=default`, {
headers: { authorization: `Bearer ${node.token}` },
})
if (!res.ok) throw new Error(`personality read failed (${res.status})`)
const j = (await res.json()) as { content?: string; exists?: boolean }
return { content: j.content ?? '', exists: !!j.exists }
}
/**
* Overwrite one of the agent's makeup files (PUT /api/personality/{file}) and
* reload so the agent re-reads it. The agent loads these each session anyway, but
* we fire a best-effort reload to apply it right away (the in-container watcher
* applies it otherwise). Same auth path as {@link configureTelegram}.
*/
export async function writePersonality(node: NodeRef, file: string, content: string): Promise<void> {
const auth = { authorization: `Bearer ${node.token}` }
const res = await fetch(`${node.url}/api/personality/${encodeURIComponent(file)}?agent=default`, {
method: 'PUT',
headers: { ...auth, 'content-type': 'application/json' },
body: JSON.stringify({ content }),
})
if (!res.ok) throw new Error(`personality write failed (${res.status})`)
try {
await fetch(`${node.url}/admin/reload`, { method: 'POST', headers: auth })
} catch {
/* watcher will apply it */
}
}
export interface SubscribeOptions { export interface SubscribeOptions {
/** Aborts the whole reconnect loop when fired. */ /** Aborts the whole reconnect loop when fired. */
signal?: AbortSignal signal?: AbortSignal
@@ -325,6 +388,14 @@ export interface NodeBridge {
* starts. Resolves `true` on success, `false` if no node is registered; * starts. Resolves `true` on success, `false` if no node is registered;
* throws if the node rejects the config write or reload. */ * throws if the node rejects the config write or reload. */
configureTelegram(teamId: string, token: string): Promise<boolean> configureTelegram(teamId: string, token: string): Promise<boolean>
/** Set the on-board agent's name so it adopts it. `true` on success, `false` if
* no node is registered; throws if the node rejects the write. */
setIdentity(teamId: string, name: string): Promise<boolean>
/** Read one of the agent's makeup ("personality") files. `null` if no node. */
getPersonality(teamId: string, file: string): Promise<{ content: string; exists: boolean } | null>
/** Overwrite one of the agent's makeup files + reload. `false` if no node;
* throws if the node rejects the write. */
putPersonality(teamId: string, file: string, content: string): Promise<boolean>
/** Stream one team's node activity to a participant. Returns an unsubscribe fn. */ /** Stream one team's node activity to a participant. Returns an unsubscribe fn. */
onTeamActivity(teamId: string, listener: (e: WsEvent) => void): () => void onTeamActivity(teamId: string, listener: (e: WsEvent) => void): () => void
stopAll(): void stopAll(): void
@@ -338,6 +409,9 @@ export interface NodeBridgeDeps {
send?: (n: NodeRef, m: string, agent?: string) => Promise<void> send?: (n: NodeRef, m: string, agent?: string) => Promise<void>
sendAndWait?: (n: NodeRef, m: string, agent?: string) => Promise<string> sendAndWait?: (n: NodeRef, m: string, agent?: string) => Promise<string>
setTelegram?: (n: NodeRef, token: string) => Promise<void> setTelegram?: (n: NodeRef, token: string) => Promise<void>
setIdentity?: (n: NodeRef, name: string) => Promise<void>
readPersonality?: (n: NodeRef, file: string) => Promise<{ content: string; exists: boolean }>
writePersonality?: (n: NodeRef, file: string, content: string) => Promise<void>
subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void, onStatus: (online: boolean) => void) => () => void subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void, onStatus: (online: boolean) => void) => () => void
} }
@@ -353,6 +427,9 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
const send = deps.send ?? sendPrompt const send = deps.send ?? sendPrompt
const sendAndWait = deps.sendAndWait ?? promptAndWait const sendAndWait = deps.sendAndWait ?? promptAndWait
const setTelegram = deps.setTelegram ?? configureTelegram const setTelegram = deps.setTelegram ?? configureTelegram
const applyIdentity = deps.setIdentity ?? configureIdentity
const doReadPersonality = deps.readPersonality ?? readPersonality
const doWritePersonality = deps.writePersonality ?? writePersonality
const subscribe = deps.subscribe ?? ((n, on, onStatus) => subscribeNodeEvents(n, on, { onStatus })) const subscribe = deps.subscribe ?? ((n, on, onStatus) => subscribeNodeEvents(n, on, { onStatus }))
const online = new Map<string, boolean>() const online = new Map<string, boolean>()
const stops = new Map<string, () => void>() const stops = new Map<string, () => void>()
@@ -408,6 +485,23 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
await setTelegram(node, token) await setTelegram(node, token)
return true return true
}, },
async setIdentity(teamId, name) {
const node = registry.get(teamId)
if (!node) return false
await applyIdentity(node, name)
return true
},
async getPersonality(teamId, file) {
const node = registry.get(teamId)
if (!node) return null
return doReadPersonality(node, file)
},
async putPersonality(teamId, file, content) {
const node = registry.get(teamId)
if (!node) return false
await doWritePersonality(node, file, content)
return true
},
onTeamActivity(teamId, listener) { onTeamActivity(teamId, listener) {
let set = teamListeners.get(teamId) let set = teamListeners.get(teamId)
if (!set) { if (!set) {