Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,27 @@ over an stdin/stdout RPC protocol. The server starts one **Prime**
agent per session and spawns sub-agents on demand, streaming their events to the UI over
Socket.IO rooms.

- **REST API** (`/api/*`) — sessions CRUD, file uploads and artifact serving, agent bundles,
global memory, and current-user lookup.
- **REST API** (`/api/*`) — sessions CRUD, external session launches, file uploads and artifact
serving, agent bundles, global memory, and current-user lookup.
- **Internal API** (`/internal/*`) — called by agents (guarded by a bearer token) to spawn
and message sub-agents, read/write memory, manage triggers, and make sandboxed egress
requests through an allowlist.
- **WebSocket events** — streaming assistant deltas, tool/thinking activity, sub-agent roster
updates, memory suggestions, trigger updates, and chat messages.

External systems can create a bundle-backed session and immediately prompt its Prime agent:

```bash
curl -u "$INGRESS_USERNAME:$INGRESS_PASSWORD" \
-H "content-type: application/json" \
-d '{"bundleId":"tangle-oss","prompt":"Investigate the latest failed run"}' \
https://tangent.example.com/api/session-launches
```

The endpoint returns `201 Created` with `{ "sessionId": "..." }`. Configure machine
authentication for this path at the deployment ingress or service proxy; shared credentials
must not be embedded in browser code.

State lives in two places: session **metadata** in SQLite (`sessions`, `sessionAssets`,
`sessionAgents` tables), and per-session **data** on disk — artifacts, uploads, memory files,
and append-only JSONL chat logs under each session's folder. Schema changes are managed with
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { createInternalMemoryRouter } from "./routes/internalMemory.ts";
import { createInternalSessionRouter } from "./routes/internalSession.ts";
import { createInternalTriggersRouter } from "./routes/internalTriggers.ts";
import { createMeRouter } from "./routes/me.ts";
import { createSessionLaunchesRouter } from "./routes/sessionLaunches.ts";
import { createSessionsRouter } from "./routes/sessions/index.ts";
import {
createAgentEventHandler,
Expand Down Expand Up @@ -99,6 +100,15 @@ app.use(
"/api/sessions",
createSessionsRouter(store, pi, triggers, triggerEngine, agentBundleStore),
);
app.use(
"/api/session-launches",
createSessionLaunchesRouter({
store,
pi,
triggerEngine,
agentBundleStore,
}),
);
app.use("/api/agent-bundles", createAgentBundlesRouter(agentBundleStore));
app.use("/api/global-memory", createGlobalMemoryRouter(memory));
// Returns the current user, derived from the Oktasso JWT cookie.
Expand Down
135 changes: 135 additions & 0 deletions apps/server/src/routes/sessionLaunches.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "node:test";

import type { Session } from "@tangent/shared/contracts.ts";
import express from "express";
import { strToU8, zipSync } from "fflate";

import { errorHandler } from "../middleware/errorHandler.ts";
import type { PiAgentManager } from "../pi/piAgentManager.ts";
import type { TriggerEngine } from "../pi/triggers/triggerEngine.ts";
import type { AgentBundleStore } from "../store/agentBundleStore.ts";
import type { SessionStore } from "../store/sessionStore.ts";
import { createSessionLaunchesRouter } from "./sessionLaunches.ts";

const BUNDLE = Buffer.from(
zipSync({
"tangent.yaml": strToU8(`schemaVersion: 1
id: test-bundle
name: Test Bundle
version: 1.0.0
prime:
systemPrompt: prompts/prime.md
tools: []
`),
"prompts/prime.md": strToU8("You are Prime."),
}),
);

function launchDependencies(rootPath: string, prompts: string[]) {
const session: Session = {
id: "session-1",
name: "Session 1",
rootPath,
status: "created",
archived: false,
createdAt: "2026-08-12T00:00:00.000Z",
updatedAt: "2026-08-12T00:00:00.000Z",
};
const store = {
createSession: async () => session,
attachConfig: async (_id, config) => ({ ...session, config }),
appendMessage: async () => {},
deleteSession: async () => true,
} satisfies Pick<
SessionStore,
"createSession" | "attachConfig" | "appendMessage" | "deleteSession"
>;
const pi = {
ensure: () => {},
prompt: (_sessionId, _rootPath, prompt) => prompts.push(prompt),
dispose: () => {},
} satisfies Pick<PiAgentManager, "ensure" | "prompt" | "dispose">;
const triggerEngine = {
seed: () => {},
dispose: () => {},
} satisfies Pick<TriggerEngine, "seed" | "dispose">;
const agentBundleStore = {
readBundle: async () => BUNDLE,
} satisfies Pick<AgentBundleStore, "readBundle">;

return { store, pi, triggerEngine, agentBundleStore };
}

async function startApp() {
const parent = mkdtempSync(path.join(tmpdir(), "session-launches-"));
const rootPath = path.join(parent, "session-1");
mkdirSync(rootPath);
const prompts: string[] = [];
const app = express();
app.use(express.json());
app.use(
"/api/session-launches",
createSessionLaunchesRouter(launchDependencies(rootPath, prompts)),
);
app.use(errorHandler);

const server = createServer(app);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Test server did not bind to a TCP port");
}

return {
url: `http://127.0.0.1:${address.port}/api/session-launches`,
prompts,
close: async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
rmSync(parent, { recursive: true, force: true });
},
};
}

async function post(url: string, body: unknown): Promise<Response> {
return fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}

test("launches and prompts a session", async () => {
const app = await startApp();
try {
const response = await post(app.url, {
bundleId: "test-bundle",
prompt: "Investigate the failure",
});

assert.equal(response.status, 201);
assert.deepEqual(await response.json(), { sessionId: "session-1" });
assert.deepEqual(app.prompts, ["Investigate the failure"]);
} finally {
await app.close();
}
});

test("rejects malformed launch requests", async () => {
const app = await startApp();
try {
const response = await post(app.url, {
bundleId: "test-bundle",
prompt: " ",
});

assert.equal(response.status, 400);
assert.deepEqual(app.prompts, []);
} finally {
await app.close();
}
});
59 changes: 59 additions & 0 deletions apps/server/src/routes/sessionLaunches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type {
LaunchSessionRequest,
LaunchSessionResponse,
} from "@tangent/shared/contracts.ts";
import { Router } from "express";
import { z } from "zod";

import { getValidated, validate } from "../middleware/validate.ts";
import type { PiAgentManager } from "../pi/piAgentManager.ts";
import type { TriggerEngine } from "../pi/triggers/triggerEngine.ts";
import type { AgentBundleStore } from "../store/agentBundleStore.ts";
import type { SessionStore } from "../store/sessionStore.ts";
import {
provisionSession,
SessionProvisioningError,
} from "./sessions/provisionSession.ts";

const launchSessionSchema = z
.object({
bundleId: z.string().trim().min(1),
prompt: z.string().trim().min(1),
})
.strict();

interface SessionLaunchDependencies {
store: Pick<
SessionStore,
"createSession" | "attachConfig" | "appendMessage" | "deleteSession"
>;
pi: Pick<PiAgentManager, "ensure" | "prompt" | "dispose">;
triggerEngine: Pick<TriggerEngine, "seed" | "dispose">;
agentBundleStore: Pick<AgentBundleStore, "readBundle">;
}

export function createSessionLaunchesRouter(
dependencies: SessionLaunchDependencies,
): Router {
const router = Router();
router.post(
"/",
validate({ body: launchSessionSchema }),
async (req, res) => {
const input = getValidated<LaunchSessionRequest>(req).body;

try {
const session = await provisionSession(dependencies, input);
const response: LaunchSessionResponse = { sessionId: session.id };
res.status(201).json(response);
} catch (error) {
if (error instanceof SessionProvisioningError) {
res.status(error.status).json({ error: error.message });
return;
}
throw error;
}
},
);
return router;
}
Loading
Loading