Skip to content
Closed
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: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,23 @@ Socket.IO rooms.
- **WebSocket events** — streaming assistant deltas, tool/thinking activity, sub-agent roster
updates, memory suggestions, trigger updates, and chat messages.

### External session creation

External tools can create a bundle-backed session and immediately send its first message to
Prime through the dedicated `POST /api/session-launches` endpoint.

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

The endpoint returns `201 Created` with the new session. Basic Auth is configured at the
deployment ingress, not inside Express. See
[`docs/server/external-session-launches.md`](docs/server/external-session-launches.md) for the
full contract and deployment requirements.

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
16 changes: 15 additions & 1 deletion apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ 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 { DefaultSessionProvisioner } from "./routes/sessions/sessionProvisioner.ts";
import {
createAgentEventHandler,
createAgentMessageHandler,
Expand Down Expand Up @@ -78,6 +80,12 @@ const pi = new PiAgentManager(

// Drives schedule timers and callback firings, delivering prompts to Prime.
const triggerEngine = new TriggerEngine(io, store, pi, triggers);
const sessionProvisioner = new DefaultSessionProvisioner(
store,
pi,
triggerEngine,
agentBundleStore,
);

app.get("/api/health", (req, res) => {
const cookies = Object.fromEntries(
Expand All @@ -97,7 +105,13 @@ app.get("/api/health", (req, res) => {

app.use(
"/api/sessions",
createSessionsRouter(store, pi, triggers, triggerEngine, agentBundleStore),
createSessionsRouter(store, pi, triggers, triggerEngine, sessionProvisioner),
);
// External automation entry point. Deployments authenticate this path at their
// ingress or service proxy before forwarding requests to Tangent Shell.
app.use(
"/api/session-launches",
createSessionLaunchesRouter(sessionProvisioner),
);
app.use("/api/agent-bundles", createAgentBundlesRouter(agentBundleStore));
app.use("/api/global-memory", createGlobalMemoryRouter(memory));
Expand Down
143 changes: 143 additions & 0 deletions apps/server/src/routes/sessionLaunches.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { test } from "node:test";

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

import { errorHandler } from "../middleware/errorHandler.ts";
import {
createSessionLaunchesRouter,
launchSessionSchema,
} from "./sessionLaunches.ts";
import {
AgentBundleNotFoundError,
InvalidAgentBundleError,
type ProvisionSessionInput,
type SessionProvisioner,
} from "./sessions/sessionProvisioner.ts";

function session(): Session {
return {
id: "session-1",
name: "Session 1",
rootPath: "/tmp/session-1",
status: "created",
archived: false,
createdAt: "2026-08-11T00:00:00.000Z",
updatedAt: "2026-08-11T00:00:00.000Z",
};
}

async function startApp(provisioner: SessionProvisioner) {
const app = express();
app.use(express.json());
app.use("/api/session-launches", createSessionLaunchesRouter(provisioner));
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`,
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
};
}

function fakeProvisioner(
create: (input: ProvisionSessionInput) => Promise<Session>,
): SessionProvisioner {
return { create };
}

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

test("launch schema requires exactly bundleId and prompt", () => {
assert.equal(
launchSessionSchema.safeParse({ bundleId: "bundle", prompt: "Run" })
.success,
true,
);
assert.equal(
launchSessionSchema.safeParse({ bundleId: "bundle" }).success,
false,
);
assert.equal(
launchSessionSchema.safeParse({
bundleId: "bundle",
prompt: "Run",
name: "Unexpected",
}).success,
false,
);
});

test("launch endpoint creates and prompts a session", async () => {
let received: ProvisionSessionInput | undefined;
const provisioner = fakeProvisioner(async (input) => {
received = input;
return session();
});
const app = await startApp(provisioner);

try {
const response = await post(app.url, {
bundleId: "tangle-oss",
prompt: "Investigate the latest failed run",
});

assert.equal(response.status, 201);
assert.deepEqual(await response.json(), {
sessionId: "session-1",
} satisfies LaunchSessionResponse);
assert.equal(received?.bundleId, "tangle-oss");
assert.equal(received?.prompt, "Investigate the latest failed run");
} finally {
await app.close();
}
});

test("launch endpoint rejects malformed requests", async () => {
const provisioner = fakeProvisioner(async () => session());
const app = await startApp(provisioner);

try {
const response = await post(app.url, { bundleId: "tangle-oss" });
assert.equal(response.status, 400);
} finally {
await app.close();
}
});

test("launch endpoint reports missing and invalid bundles", async () => {
const missingApp = await startApp(
fakeProvisioner(async () => {
throw new AgentBundleNotFoundError("Agent bundle not found");
}),
);
const invalidApp = await startApp(
fakeProvisioner(async () => {
throw new InvalidAgentBundleError("Invalid manifest");
}),
);

try {
const body = { bundleId: "bundle", prompt: "Run" };
assert.equal((await post(missingApp.url, body)).status, 404);
assert.equal((await post(invalidApp.url, body)).status, 400);
} finally {
await missingApp.close();
await invalidApp.close();
}
});
62 changes: 62 additions & 0 deletions apps/server/src/routes/sessionLaunches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type {
LaunchSessionRequest,
LaunchSessionResponse,
} from "@tangent/shared/contracts.ts";
import { type Request, type Response, Router } from "express";
import { z } from "zod";

import { resolveUserIdentity } from "../auth/identity.ts";
import { getValidated, validate } from "../middleware/validate.ts";
import {
AgentBundleNotFoundError,
InvalidAgentBundleError,
type SessionProvisioner,
} from "./sessions/sessionProvisioner.ts";

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

async function handleLaunchSession(
provisioner: SessionProvisioner,
req: Request,
res: Response,
): Promise<void> {
const input = getValidated<LaunchSessionRequest>(req).body;

try {
const session = await provisioner.create({
...input,
user: resolveUserIdentity(req.headers.cookie) ?? undefined,
});
const response: LaunchSessionResponse = {
sessionId: session.id,
};
res.status(201).json(response);
} catch (error) {
if (error instanceof AgentBundleNotFoundError) {
res.status(404).json({ error: error.message });
return;
}
if (error instanceof InvalidAgentBundleError) {
res.status(400).json({ error: error.message });
return;
}
throw error;
}
}

export function createSessionLaunchesRouter(
provisioner: SessionProvisioner,
): Router {
const router = Router();
router.post(
"/",
validate({ body: launchSessionSchema }),
(req: Request, res: Response) => handleLaunchSession(provisioner, req, res),
);
return router;
}
60 changes: 32 additions & 28 deletions apps/server/src/routes/sessions/createSession.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import type { Request, Response } from "express";

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 { handleCreateSession } from "./handlers.ts";
import { createSessionSchema } from "./schemas.ts";
import {
AgentBundleNotFoundError,
InvalidAgentBundleError,
type SessionProvisioner,
} from "./sessionProvisioner.ts";

class TestResponse {
statusCode = 200;
Expand All @@ -25,6 +24,14 @@ class TestResponse {
}
}

function rejectingProvisioner(error: Error): SessionProvisioner {
return {
create: async () => {
throw error;
},
};
}

test("createSessionSchema rejects blank create requests", () => {
assert.equal(createSessionSchema.safeParse({}).success, false);
assert.equal(createSessionSchema.safeParse({ bundleId: "" }).success, false);
Expand All @@ -35,34 +42,31 @@ test("createSessionSchema rejects blank create requests", () => {
});

test("handleCreateSession returns 404 for unknown bundle ids", async () => {
let createSessionCalled = false;
let requestedBundleId: string | undefined;
const store = {
createSession: async () => {
createSessionCalled = true;
throw new Error("createSession should not be called");
},
} as unknown as SessionStore;
const agentBundleStore = {
readBundle: async (id: string) => {
requestedBundleId = id;
return undefined;
},
} as AgentBundleStore;
const response = new TestResponse();

await handleCreateSession(
store,
{} as PiAgentManager,
{} as TriggerEngine,
agentBundleStore,
{ headers: {} } as Request,
rejectingProvisioner(
new AgentBundleNotFoundError("Agent bundle not found"),
),
{ headers: {} },
{ bundleId: "missing-bundle" },
response as unknown as Response,
response,
);

assert.equal(requestedBundleId, "missing-bundle");
assert.equal(createSessionCalled, false);
assert.equal(response.statusCode, 404);
assert.deepEqual(response.body, { error: "Agent bundle not found" });
});

test("handleCreateSession returns 400 for invalid bundles", async () => {
const response = new TestResponse();

await handleCreateSession(
rejectingProvisioner(new InvalidAgentBundleError("Invalid manifest")),
{ headers: {} },
{ bundleId: "broken-bundle" },
response,
);

assert.equal(response.statusCode, 400);
assert.deepEqual(response.body, { error: "Invalid manifest" });
});
Loading
Loading