The module named /var/lib/letta, a layout no definition may carry (ADR
0112); state is now a placed directory holding the bindings and secrets.
letta had no route, while the server it replaces is reached by its public
name (a workflow calls it there). It now contributes one for its `web`
endpoint; reach is the assignment's.
The server needs an OpenAI key for agents on OpenAI models, and nothing
gave it one: `openai-api-key` is an own-secret, accepted from the
operator (a key someone chose, not one the mesh can mint). The
server-password is accepted the same way where a server already has
clients. Both, and the database password inside LETTA_PG_URI, stay in the
server's environment: letta 0.6.x reads settings from the environment
only, and its startup.sh starts an embedded PostgreSQL unless
LETTA_PG_URI is set - the declared reason now says so.
The runtime's tools never authenticated: the client sent only a Bearer
token, and 0.6.x's --secure mode checks X-BARE-PASSWORD ("password <it>")
and answers 401 otherwise. The client now sends both. Its password comes
from the runtime config file (the key client.ts reads first) instead of an
env-file, so the runtime container no longer carries a secret in its
environment.
Image: the same 0.6.8 image, now pinned by the index digest ace runs
rather than its amd64 manifest.
Verified: catalogue tests with MESH_CATALOGUE set; tsc -p tsconfig.json in
the mesh-tools build image. Throwaway containers: a fresh 0.6.8 with its
embedded PG and two blocks made through the API; stopped, copied, dumped
from the copy; restored (schema letta + vector pre-made by the superuser,
--no-owner --role, search_path set on the database as the original had
it) into a grant-shaped database on the postgres module's pgvector image
(PG17); started in this shape: alembic finds nothing to do, both blocks
are there, a wrong password is refused with 401, and the patched client
lists agents. Test containers and data removed.
90 lines
3.3 KiB
TypeScript
90 lines
3.3 KiB
TypeScript
// The Letta API client — letta's own code, living in the module (novox/hq ADR 0039). Its tools
|
|
// import it; nothing outside letta does.
|
|
//
|
|
// Letta authenticates with a single server password. That password is a mesh own-secret handed to
|
|
// both the server (LETTA_SERVER_PASSWORD) and this client, through the runtime config file the mesh
|
|
// mounts (its `password` key) — so the module's tools are live without anything configured by hand.
|
|
// Where a server already has clients, the password is accepted rather than minted.
|
|
|
|
import { readFileSync } from "node:fs";
|
|
|
|
export interface LettaAgent {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
export interface LettaMessage {
|
|
id?: string;
|
|
role?: string;
|
|
text?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
function meshConfig(file?: string): Record<string, string> {
|
|
if (!file) return {};
|
|
try {
|
|
return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
export class LettaClient {
|
|
readonly baseUrl: string;
|
|
|
|
constructor(
|
|
url: string,
|
|
private readonly password: string,
|
|
) {
|
|
this.baseUrl = url.replace(/\/+$/, "");
|
|
}
|
|
|
|
/**
|
|
* Build from the module's resolved environment. URL and password come from the runtime config
|
|
* file first (MESH_LETTA_CONFIG_FILE), then the mesh's own names, then the bare LETTA_* names. A
|
|
* password is required — Letta rejects unauthenticated calls when SECURE is on — so this throws
|
|
* rather than hand back a client that fails on first use.
|
|
*/
|
|
static fromEnv(env: NodeJS.ProcessEnv = process.env): LettaClient {
|
|
const cfg = meshConfig(env.MESH_LETTA_CONFIG_FILE);
|
|
const url = cfg.url ?? env.MESH_LETTA_URL ?? env.LETTA_URL ?? "http://127.0.0.1:8283";
|
|
const password = cfg.password ?? env.MESH_LETTA_PASSWORD ?? env.LETTA_SERVER_PASSWORD;
|
|
if (!password) throw new Error("no Letta password — set MESH_LETTA_PASSWORD");
|
|
return new LettaClient(url, password);
|
|
}
|
|
|
|
private async request<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
|
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
...options,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
// The server's --secure mode checks X-BARE-PASSWORD ("password <it>") and answers a Bearer
|
|
// token alone with 401 (letta/server/rest_api/app.py, 0.6.x). Both are sent: Bearer is what
|
|
// later servers read.
|
|
"X-BARE-PASSWORD": `password ${this.password}`,
|
|
Authorization: `Bearer ${this.password}`,
|
|
...(options.headers as Record<string, string> | undefined),
|
|
},
|
|
});
|
|
if (!res.ok) throw new Error(`letta ${path}: ${res.status} ${await res.text()}`);
|
|
if (res.status === 204) return null as T;
|
|
const text = await res.text();
|
|
return (text ? JSON.parse(text) : null) as T;
|
|
}
|
|
|
|
async listAgents(): Promise<LettaAgent[]> {
|
|
return (await this.request<LettaAgent[]>(`/v1/agents/`)) ?? [];
|
|
}
|
|
|
|
async getMessages(agentId: string, limit = 20): Promise<LettaMessage[]> {
|
|
return (await this.request<LettaMessage[]>(`/v1/agents/${agentId}/messages?limit=${limit}`)) ?? [];
|
|
}
|
|
|
|
async sendMessage(agentId: string, text: string): Promise<unknown> {
|
|
return this.request(`/v1/agents/${agentId}/messages`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ messages: [{ role: "user", content: text }] }),
|
|
});
|
|
}
|
|
}
|