From 9dd6b40934fa297bd22cc5f5d0ca9eb2fbd65b3c Mon Sep 17 00:00:00 2001
From: Tomas Dvorak
Date: Mon, 28 Sep 2026 11:30:33 +0200
Subject: [PATCH] feat: agent setup prompt for new projects
A "Set up with an AI agent" dialog that mints a write-scoped API key and
renders a copyable prompt handing a coding agent everything needed to wire
a repository into a Primora project: telemetry ingest for any language,
db-connections for tracked databases, MCP/CLI agent access, and
verification steps. Opens at the end of new-project onboarding; also
reachable from the project dashboard and Projects page settings.
Authored By: TDvorak
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
CHANGELOG.md | 1 +
README.md | 1 +
apps/frontend/src/App.tsx | 21 +++
.../src/components/AgentSetupModal.tsx | 156 ++++++++++++++++++
.../src/components/OnboardingModal.tsx | 13 +-
.../src/components/ProjectDashboard.tsx | 8 +
apps/frontend/src/components/index.ts | 1 +
.../src/lib/__tests__/agentPrompt.test.ts | 51 ++++++
apps/frontend/src/lib/agent-prompt.ts | 122 ++++++++++++++
apps/frontend/src/pages/ProjectsPage.tsx | 9 +-
10 files changed, 381 insertions(+), 2 deletions(-)
create mode 100644 apps/frontend/src/components/AgentSetupModal.tsx
create mode 100644 apps/frontend/src/lib/__tests__/agentPrompt.test.ts
create mode 100644 apps/frontend/src/lib/agent-prompt.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b489e2..d016164 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac
### Added
+- **Agent setup prompt** — a "Set up with an AI agent" dialog that mints a fresh `write`-scoped API key (ingest + reads + non-admin mutations, never key-minting) and renders a copyable prompt handing a coding agent everything it needs: the deployment endpoint, project id, the key itself, plus tasks to instrument error/metric/log/heartbeat reporting per component (`POST /api/v1/ingest` — works from any language), register the app's databases via `db-connections` (postgres → cassandra), wire the MCP server / CLI for future agents, and verify with a test event + deploy marker. Opens at the end of the new-project onboarding and is re-reachable from the project dashboard and Projects page; keys are shown only inside the prompt (revoke under Settings → API keys).
- **Local secrets vault in the CLI** (`@primora/cli` 0.6.0) — `primora vault init/status` and `primora secrets set|get|list|rm|import` manage an encrypted `~/.config/primora/vault.bin` (Argon2id 64 MiB/3/4 → XChaCha20-Poly1305, header bound as AEAD AAD, atomic writes). Entries carry value + optional `url`/`notes` metadata. Password via `PRIMORA_VAULT_PASSWORD`, `--password-file`, or hidden prompt; file path overridable with `PRIMORA_VAULT`. Clean-room format — no external vault compatibility.
- **Vault sessions** — `primora vault unlock --ttl 900` proves the password once and writes a TTL'd `vault.session` (0600); agents and scripts use the vault without the password. `primora vault lock` revokes immediately.
- **`primora agent` credential broker** — wraps a child process with dummy tokens and a loopback base URL; the proxy attaches the real secret on the wire per grant (TTL, cap 3600 s). Presets: `anthropic`, `openai`, `openrouter`, `groq`, `deepseek`, `xai`, `github`, `npm`; `--upstream name=https://…` covers arbitrary Bearer APIs. Base-URL broker, not TLS MITM — tools must honor a base-URL override.
diff --git a/README.md b/README.md
index a3a44f5..1d10450 100644
--- a/README.md
+++ b/README.md
@@ -55,6 +55,7 @@ Supabase, with no hosted tier - your data stays on your hardware.
- **Automation** - scheduled jobs, inbound webhooks, telemetry alert rules, and `secret://` refs resolved at delivery.
- **Realtime** - SSE event stream with a typed client SDK (`@primora/client`) — channel subscriptions, presence counts, and client-published `custom.*` events that fan out to webhooks and functions.
- **API keys** - `prm__` credentials, shown once.
+- **Agent setup** - every project can mint a `write`-scoped key and generate a copyable AI-agent prompt that wires telemetry, database tracking, and MCP/CLI access into any repository.
- **Audit log** - every mutating request recorded, with CSV/JSON export.
- **Demo mode** - fully client-side seeded workspace via `?demo=true`.
diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx
index 1e09fc3..53b4355 100644
--- a/apps/frontend/src/App.tsx
+++ b/apps/frontend/src/App.tsx
@@ -29,6 +29,7 @@ import { authClient, fetchApiToken } from "./lib/auth-client";
import { configureApiToken, errorMessage } from "./lib/api";
import { isDemoMode, disableDemoMode, enableDemoMode, demoSession, demoService } from "./lib/demo-mode";
import {
+ AgentSetupModal,
AppShell,
CommandPaletteEnhanced,
NetworkError,
@@ -127,6 +128,7 @@ export default function App() {
const [name, setName] = createSignal("");
const [activeView, setActiveView] = createSignal("dashboard");
const [showOnboarding, setShowOnboarding] = createSignal(false);
+ const [agentSetupOpen, setAgentSetupOpen] = createSignal(false);
const [paletteOpen, setPaletteOpen] = createSignal(false);
const [theme, setTheme] = createSignal<"dark" | "light">(
(localStorage.getItem("primora_theme") as "dark" | "light") ?? "dark",
@@ -1696,6 +1698,7 @@ export default function App() {
overview={projectOverview()}
recentAudit={auditLogs()}
onNavigate={(view) => setActiveView(view as ViewType)}
+ onAgentSetup={() => setAgentSetupOpen(true)}
/>
@@ -1716,6 +1719,7 @@ export default function App() {
onDeleteProject={deleteActiveProject}
onSelectProject={setSelectedProjectID}
onNavigateToDashboard={() => setActiveView("dashboard")}
+ onAgentSetup={() => setAgentSetupOpen(true)}
/>
@@ -1966,6 +1970,23 @@ export default function App() {
isOpen={showOnboarding()}
projectName={activeProject()?.name ?? "Your project"}
onClose={() => setShowOnboarding(false)}
+ onDone={() => {
+ setShowOnboarding(false);
+ setAgentSetupOpen(true);
+ }}
+ />
+
+ setAgentSetupOpen(false)}
+ project={activeProject()}
+ canManage={canUpdateProject() || isDemo}
+ demoMode={isDemo}
+ onKeyCreated={() => {
+ void refetchAPIKeys();
+ void refetchAuditLogs();
+ void refreshProjectOverviewSnapshot();
+ }}
/>
);
diff --git a/apps/frontend/src/components/AgentSetupModal.tsx b/apps/frontend/src/components/AgentSetupModal.tsx
new file mode 100644
index 0000000..e0ecd52
--- /dev/null
+++ b/apps/frontend/src/components/AgentSetupModal.tsx
@@ -0,0 +1,156 @@
+import { Show, createEffect, createSignal } from "solid-js";
+import { ProjectsService } from "@primora/api-client";
+import { demoService } from "../lib/demo-mode";
+import { buildAgentPrompt } from "../lib/agent-prompt";
+import { errorMessage } from "../lib/api";
+import { Modal, ModalFooter } from "./Modal";
+import { IconCheck, IconCopy, IconKey, IconRefresh } from "./Icons";
+
+export interface AgentSetupProject {
+ id: string;
+ name: string;
+ slug: string;
+}
+
+interface AgentSetupModalProps {
+ open: boolean;
+ onClose: () => void;
+ project?: AgentSetupProject;
+ canManage: boolean;
+ demoMode: boolean;
+ /** called after a key is minted so the app can refetch keys/audit/overview */
+ onKeyCreated?: () => void;
+}
+
+export function AgentSetupModal(props: AgentSetupModalProps) {
+ const [prompt, setPrompt] = createSignal("");
+ const [keyPrefix, setKeyPrefix] = createSignal("");
+ const [busy, setBusy] = createSignal(false);
+ const [error, setError] = createSignal("");
+ const [copied, setCopied] = createSignal(false);
+
+ const generate = async () => {
+ const project = props.project;
+ if (!project || busy()) return;
+ setBusy(true);
+ setError("");
+ setCopied(false);
+ try {
+ const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, "");
+ const result = props.demoMode
+ ? await demoService.createApiKey({ requestBody: { name: `agent-setup-${stamp}` } })
+ : await ProjectsService.createApiKey({
+ projectId: project.id,
+ requestBody: { name: `agent-setup-${stamp}`, scopes: ["write"] },
+ });
+ setKeyPrefix(result.prefix);
+ setPrompt(
+ buildAgentPrompt({
+ baseUrl: window.location.origin,
+ projectId: project.id,
+ projectName: project.name,
+ projectSlug: project.slug,
+ apiKey: result.secret,
+ }),
+ );
+ props.onKeyCreated?.();
+ } catch (e) {
+ setError(errorMessage(e, "Could not create an API key"));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ // Mint a fresh write-scoped key each time the modal opens — secrets are
+ // shown once, so a cached prompt would carry a dead credential. The error
+ // guard prevents a retry loop when minting fails; the user retries manually.
+ createEffect(() => {
+ if (props.open && props.project && props.canManage && !prompt() && !busy() && !error()) {
+ void generate();
+ }
+ if (!props.open) {
+ setPrompt("");
+ setKeyPrefix("");
+ setError("");
+ setCopied(false);
+ }
+ });
+
+ const copyPrompt = async () => {
+ try {
+ await navigator.clipboard.writeText(prompt());
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ /* clipboard unavailable — the text is selectable instead */
+ }
+ };
+
+ return (
+
+
+ Generating credentials requires the project admin role. Ask a project
+ admin to open this dialog and copy the prompt for you.
+
+ }
+ >
+
+
+ Creating a write-scoped API key…
+
+
+ void generate()}>
+ Try again
+
+
+
+ }
+ >
+
+
+ Fresh key {keyPrefix()}… minted — shown only inside this prompt. {" "}
+ It grants write access to this project; revoke it under Settings → API keys if the
+ prompt leaks or the agent finishes.
+
+
+
+ {prompt()}
+
+
+ void generate()} disabled={busy()}>
+
+ New key & prompt
+
+
+
+
+ Done
+
+ void copyPrompt()}>
+ Copied>}>
+ Copy prompt
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/frontend/src/components/OnboardingModal.tsx b/apps/frontend/src/components/OnboardingModal.tsx
index 225a935..01d478f 100644
--- a/apps/frontend/src/components/OnboardingModal.tsx
+++ b/apps/frontend/src/components/OnboardingModal.tsx
@@ -6,6 +6,8 @@ interface OnboardingModalProps {
isOpen: boolean;
projectName: string;
onClose: () => void;
+ /** fired on the final "Done" step instead of onClose — used to chain into agent setup */
+ onDone?: () => void;
}
const steps = [
@@ -88,7 +90,16 @@ export function OnboardingModal(props: OnboardingModalProps) {
(step() < steps.length - 1 ? setStep(step() + 1) : handleClose())}
+ onClick={() => {
+ if (step() < steps.length - 1) {
+ setStep(step() + 1);
+ } else if (props.onDone) {
+ setStep(0);
+ props.onDone();
+ } else {
+ handleClose();
+ }
+ }}
>
{step() === steps.length - 1 ? "Done" : "Next"}
diff --git a/apps/frontend/src/components/ProjectDashboard.tsx b/apps/frontend/src/components/ProjectDashboard.tsx
index bc46dc9..507d6f4 100644
--- a/apps/frontend/src/components/ProjectDashboard.tsx
+++ b/apps/frontend/src/components/ProjectDashboard.tsx
@@ -8,6 +8,7 @@ import {
IconCollections,
IconIntegrations,
IconChevronRight,
+ IconTerminal,
} from "./Icons";
interface ProjectDashboardProps {
@@ -15,6 +16,7 @@ interface ProjectDashboardProps {
overview?: ProjectOverview;
recentAudit?: AuditLog[];
onNavigate: (view: string) => void;
+ onAgentSetup?: () => void;
}
function formatBytes(bytes?: number): string {
@@ -135,6 +137,12 @@ export function ProjectDashboard(props: ProjectDashboardProps) {
+
+ props.onAgentSetup!()}>
+
+ Agent setup
+
+
props.onNavigate("settings")}>
Keys
diff --git a/apps/frontend/src/components/index.ts b/apps/frontend/src/components/index.ts
index aaed9e3..594e9d6 100644
--- a/apps/frontend/src/components/index.ts
+++ b/apps/frontend/src/components/index.ts
@@ -1,4 +1,5 @@
// Components barrel export
+export { AgentSetupModal, type AgentSetupProject } from "./AgentSetupModal";
export { AppShell, type ViewType } from "./AppShell";
export { Badge, StatusBadge } from "./Badge";
export { Button } from "./Button";
diff --git a/apps/frontend/src/lib/__tests__/agentPrompt.test.ts b/apps/frontend/src/lib/__tests__/agentPrompt.test.ts
new file mode 100644
index 0000000..072d3c6
--- /dev/null
+++ b/apps/frontend/src/lib/__tests__/agentPrompt.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from "vitest";
+
+import { buildAgentPrompt } from "../agent-prompt";
+
+const base = {
+ baseUrl: "https://primora.example.com",
+ projectId: "proj-123",
+ projectName: "Production API",
+ projectSlug: "production-api",
+ apiKey: "prm_abcd_secretkey",
+};
+
+describe("buildAgentPrompt", () => {
+ const prompt = buildAgentPrompt(base);
+
+ it("embeds credentials verbatim", () => {
+ expect(prompt).toContain(`PRIMORA_BASE_URL=${base.baseUrl}`);
+ expect(prompt).toContain(`PRIMORA_API_KEY=${base.apiKey}`);
+ expect(prompt).toContain(`PRIMORA_PROJECT=${base.projectId}`);
+ });
+
+ it("points ingest at the deployment's API base", () => {
+ expect(prompt).toContain(`POST ${base.baseUrl}/api/v1/ingest`);
+ expect(prompt).not.toContain(`${base.baseUrl}/api/v1/api/v1`);
+ });
+
+ it("covers monitoring, databases and agent access", () => {
+ expect(prompt).toContain("/db-connections");
+ expect(prompt).toContain("deploy-markers");
+ expect(prompt).toContain("PRIMORA_PROJECT");
+ for (const t of ["postgres", "mysql", "mongodb", "redis"]) {
+ expect(prompt).toContain(t);
+ }
+ });
+
+ it("never fabricates database credentials", () => {
+ expect(prompt).toMatch(/never fabricate credentials/i);
+ });
+
+ it("trims a trailing slash on the base URL", () => {
+ const p = buildAgentPrompt({ ...base, baseUrl: "http://localhost/" });
+ expect(p).toContain("PRIMORA_BASE_URL=http://localhost\n");
+ expect(p).toContain("http://localhost/api/v1/ingest");
+ });
+
+ it("includes a concrete MCP stdio block when a source path is given", () => {
+ const p = buildAgentPrompt({ ...base, primoraSourcePath: "/opt/Primora" });
+ expect(p).toContain("/opt/Primora/apps/mcp/dist/server.js");
+ expect(p).toContain('"mcpServers"');
+ });
+});
diff --git a/apps/frontend/src/lib/agent-prompt.ts b/apps/frontend/src/lib/agent-prompt.ts
new file mode 100644
index 0000000..3614e61
--- /dev/null
+++ b/apps/frontend/src/lib/agent-prompt.ts
@@ -0,0 +1,122 @@
+/**
+ * Builds the copy-paste prompt that hands an AI coding agent everything it
+ * needs to wire a repository into a Primora project: telemetry ingest,
+ * database connections, and agent-facing access (MCP/CLI). Pure function so
+ * it can be unit-tested and reused by any surface.
+ */
+
+export interface AgentPromptInput {
+ /** Deployment origin, e.g. http://localhost — /api/v1 is appended here. */
+ baseUrl: string;
+ projectId: string;
+ projectName: string;
+ projectSlug: string;
+ /** Full prm_… key shown once at mint time. */
+ apiKey: string;
+ /** Optional absolute path to a local Primora source checkout (enables the MCP build step). */
+ primoraSourcePath?: string;
+}
+
+export function buildAgentPrompt(input: AgentPromptInput): string {
+ const base = input.baseUrl.replace(/\/+$/, "");
+ const api = `${base}/api/v1`;
+ const p = input.projectId;
+
+ const mcpSection = input.primoraSourcePath
+ ? `A Primora source checkout exists at \`${input.primoraSourcePath}\`. Build the MCP server once:
+
+\`\`\`bash
+cd ${input.primoraSourcePath} && npm run build --workspace @primora/mcp # → apps/mcp/dist/server.js
+\`\`\`
+
+Then register a stdio MCP server named \`primora\` in your client's MCP config:
+\`\`\`json
+{
+ "mcpServers": {
+ "primora": {
+ "command": "node",
+ "args": ["${input.primoraSourcePath}/apps/mcp/dist/server.js"],
+ "env": {
+ "PRIMORA_BASE_URL": "${base}",
+ "PRIMORA_API_KEY": "$PRIMORA_API_KEY",
+ "PRIMORA_PROJECT": "${p}"
+ }
+ }
+ }
+}
+\`\`\`
+This exposes 30+ \`primora_*\` tools (telemetry query, collections, storage, jobs, audit) to every agent that loads the config.`
+ : `If a Primora source checkout is available on this machine, build \`@primora/mcp\` and register it as a stdio MCP server with env \`PRIMORA_BASE_URL=${base}\`, \`PRIMORA_API_KEY\`, \`PRIMORA_PROJECT=${p}\`. Otherwise skip — the REST API above covers the same surface.`;
+
+ return `You are an AI agent. Wire this repository into the Primora project "${input.projectName}" so it reports telemetry, tracks its databases, and is reachable by future agents. Work through every section; do not stop after the first.
+
+## Credentials
+
+\`\`\`bash
+PRIMORA_BASE_URL=${base}
+PRIMORA_API_KEY=${input.apiKey}
+PRIMORA_PROJECT=${p}
+\`\`\`
+
+- API base: \`${api}\`
+- Send the key as header \`X-Primora-Key\` (alias \`X-API-Key\` also accepted).
+- Key scope is \`write\`: it covers telemetry ingest, reads, and non-admin mutations (connections, buckets, collections, webhooks). It cannot mint keys or manage members — that is intentional.
+- Put these in the project's \`.env\` (add to \`.env.example\` with placeholders) and reference them via env vars in code. Never hardcode or commit the literal key.
+
+## 1. Monitoring — instrument every component
+
+All telemetry goes to one endpoint; components self-register on first event:
+
+\`\`\`
+POST ${api}/ingest
+X-Primora-Key: $PRIMORA_API_KEY
+Content-Type: application/json
+
+{"events":[{"type":"error","component":"${input.projectSlug}-api","kind":"backend","severity":"error","message":"…","payload":{"stack":"…"},"ts":""}]}
+\`\`\`
+
+- \`type\`: \`error\` | \`metric\` | \`log\` | \`heartbeat\` | \`event\`. Batches up to 500 events.
+- \`kind\`: \`frontend\` | \`backend\` | \`database\` | \`web\` | \`android\` | \`desktop\` | \`other\`.
+- Errors with the same \`message\` (or explicit \`fingerprint\`) group into Issues.
+
+Detect this repo's stack and instrument each deployable unit — frontend, backend, workers, landing page, all of them:
+
+- **Browser / React / any web frontend**: add a small telemetry module that batches to /ingest (fetch + \`keepalive: true\`), hooks \`window.onerror\` + \`unhandledrejection\`, and flushes on \`visibilitychange\`. Component kind \`frontend\` or \`web\`.
+- **Node / TypeScript backends**: hook the framework's error middleware and \`process.on("uncaughtException")\`; send \`heartbeat "up"\` every 30s. Kind \`backend\`.
+- **Go / Python / Rust / other**: same HTTP contract — wrap panic/recover or global exception handlers and a periodic heartbeat. Any language works; there is no required SDK.
+- Name components \`${input.projectSlug}-frontend\`, \`${input.projectSlug}-api\`, \`${input.projectSlug}-worker\`, etc.
+
+Verify now: send one test event and confirm it lands (see §4).
+
+## 2. Databases — register what this app uses
+
+Ask the user for the connection details of each database this project uses — never fabricate credentials, never commit them. Register each one so it appears under Primora → Databases (schema browser, query console):
+
+\`\`\`
+POST ${api}/projects/${p}/db-connections
+X-Primora-Key: $PRIMORA_API_KEY
+
+{"name":"primary","db_type":"postgres","host":"…","port":5432,"database":"…","username":"…","password":"…","ssl":true}
+\`\`\`
+
+Supported \`db_type\`: postgres, mysql, sqlite, redis, mongodb, sqlserver, duckdb, clickhouse, cassandra. After creating each connection verify it:
+
+\`\`\`
+POST ${api}/projects/${p}/db-connections/{connectionID}/test → {"ok":true|false,"error":"…"}
+\`\`\`
+
+Report per-database status to the user. If a database is unreachable, say so — do not silently skip it.
+
+## 3. Agent access — let future agents manage this project
+
+${mcpSection}
+
+Optional CLI path: \`primora login --api-key $PRIMORA_API_KEY\`, then \`primora use\`. For wrapping third-party agents with brokered credentials (the agent never holds real keys), use \`primora agent -- -- \`.
+
+## 4. Prove it works
+
+1. \`curl -sS -X POST ${api}/ingest -H "X-Primora-Key: $PRIMORA_API_KEY" -H "Content-Type: application/json" -d '{"events":[{"type":"event","component":"setup","message":"primora.setup.done"}]}'\` — expect \`{"accepted":1}\`.
+2. Record a deploy marker: \`curl -sS -X POST ${api}/projects/${p}/deploy-markers -H "X-Primora-Key: $PRIMORA_API_KEY" -H "Content-Type: application/json" -d '{"ref":"","environment":"dev","note":"agent setup"}'\`.
+3. Finish with a short summary: components instrumented, database connections tested, and where the data lands (${base} → Telemetry, Databases, Integrations).
+`;
+}
diff --git a/apps/frontend/src/pages/ProjectsPage.tsx b/apps/frontend/src/pages/ProjectsPage.tsx
index 03bf951..d6ba00c 100644
--- a/apps/frontend/src/pages/ProjectsPage.tsx
+++ b/apps/frontend/src/pages/ProjectsPage.tsx
@@ -3,7 +3,7 @@ import type { ProjectSummary } from "@primora/api-client";
import { Badge } from "../components/Badge";
import { Modal, ModalFooter } from "../components/Modal";
import { Input, Textarea } from "../components/Input";
-import { IconPlus, IconProjects, IconArrowRight, IconEdit, IconTrash } from "../components/Icons";
+import { IconPlus, IconProjects, IconArrowRight, IconEdit, IconTrash, IconTerminal } from "../components/Icons";
interface ProjectInput {
name: string;
@@ -32,6 +32,7 @@ interface ProjectsPageProps {
onDeleteProject: () => void;
onSelectProject: (id: string) => void;
onNavigateToDashboard: () => void;
+ onAgentSetup?: () => void;
}
export function ProjectsPage(props: ProjectsPageProps) {
@@ -169,6 +170,12 @@ export function ProjectsPage(props: ProjectsPageProps) {
+
+ props.onAgentSetup!()}>
+
+ Agent setup
+
+
setSettingsOpen(true)}