@tangle-network/create-agent-app 0.44.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +9 -0
  2. package/index.mjs +183 -0
  3. package/package.json +34 -0
  4. package/template/.dev.vars.example +11 -0
  5. package/template/AGENTS.md +67 -0
  6. package/template/CLAUDE.md +6 -0
  7. package/template/CUSTOMIZE.md +83 -0
  8. package/template/KNOWLEDGE.md +73 -0
  9. package/template/README.md +41 -0
  10. package/template/_gitignore +5 -0
  11. package/template/_package.json +33 -0
  12. package/template/_tsconfig.json +16 -0
  13. package/template/_wrangler.toml +22 -0
  14. package/template/agent.config.ts +113 -0
  15. package/template/knowledge/.gitkeep +0 -0
  16. package/template/knowledge/README.md +24 -0
  17. package/template/scripts/knowledge-ingest.mjs +86 -0
  18. package/template/src/agent-app.ts +77 -0
  19. package/template/src/worker.ts +128 -0
  20. package/template/tests/agent-app.test.ts +71 -0
  21. package/template/vitest.config.ts +7 -0
  22. package/template-chat/.dev.vars.example +18 -0
  23. package/template-chat/AGENTS.md +73 -0
  24. package/template-chat/CLAUDE.md +6 -0
  25. package/template-chat/CUSTOMIZE.md +88 -0
  26. package/template-chat/README.md +50 -0
  27. package/template-chat/_gitignore +6 -0
  28. package/template-chat/_package.json +38 -0
  29. package/template-chat/_tsconfig.json +16 -0
  30. package/template-chat/_wrangler.toml +38 -0
  31. package/template-chat/agent.config.ts +73 -0
  32. package/template-chat/declarations.d.ts +9 -0
  33. package/template-chat/migrations/0001_init.sql +109 -0
  34. package/template-chat/prompts/system.md +11 -0
  35. package/template-chat/public/index.html +236 -0
  36. package/template-chat/src/chat.ts +210 -0
  37. package/template-chat/src/db/schema.ts +70 -0
  38. package/template-chat/src/env.ts +33 -0
  39. package/template-chat/src/sandbox.ts +159 -0
  40. package/template-chat/src/worker.ts +58 -0
  41. package/template-chat/tests/chat-turn.e2e.test.ts +277 -0
  42. package/template-chat/vitest.config.ts +22 -0
@@ -0,0 +1,113 @@
1
+ /**
2
+ * agent.config.ts — the DATA surface of this agent product.
3
+ *
4
+ * This is the ONE file you fill to define who the agent is, what it may propose,
5
+ * what knowledge gates its loop, and which integrations it connects. It is plain
6
+ * data consumed by `@tangle-network/agent-app`'s modules through typed seams —
7
+ * NOT behavior. Do not put control flow here; that lives in `src/` (the chat
8
+ * route + composer). See CUSTOMIZE.md for the ordered fill-checklist and AGENTS.md
9
+ * for the layering contract.
10
+ *
11
+ * Every field below is stubbed with a placeholder. Replace the placeholders; keep
12
+ * the shape. `pnpm typecheck` proves the shape; `pnpm test` proves the wiring.
13
+ */
14
+
15
+ import { defineAgentApp } from '@tangle-network/agent-app/config'
16
+
17
+ export const config = defineAgentApp({
18
+ // ① IDENTITY — who is the agent? (Discovery: "Whose job does this do, in whose
19
+ // voice, under what hard rules?")
20
+ identity: {
21
+ name: '__PROJECT_NAME__',
22
+ persona:
23
+ 'You are an operations partner for <DOMAIN>. You automate the standing ' +
24
+ 'workflows, stay grounded in real records, and route every regulated or ' +
25
+ 'client-facing step to a named human for approval. Replace this paragraph ' +
26
+ 'with the real persona — it is the spine of the system prompt.',
27
+ // Standing workflows, hard rules, tone — appended verbatim after the persona.
28
+ systemPromptFragments: [
29
+ 'Never fabricate a figure (price, coverage, identifier, regulatory clause). ' +
30
+ 'Cite a real record or say NOT ON FILE.',
31
+ ],
32
+ // Named disclaimers the UI / chat pipeline can select by id.
33
+ disclaimers: {
34
+ 'not-advice':
35
+ 'This assistant prepares proposals for a licensed human to review and ' +
36
+ 'approve. It does not itself give regulated advice or take regulated action.',
37
+ },
38
+ },
39
+
40
+ // ② TAXONOMY — what can the agent PROPOSE, and which proposals are regulated?
41
+ // (Discovery: "Which actions change client state or are legally gated, so a
42
+ // certified human must approve before they execute?")
43
+ // `regulatedTypes` MUST be a subset of `proposalTypes`. Regulated proposals
44
+ // CANNOT execute without a named certified approver — this is the
45
+ // human-in-the-loop invariant. Keep regulated steps regulated.
46
+ taxonomy: {
47
+ proposalTypes: ['recommend', 'contact', 'escalate'],
48
+ regulatedTypes: ['escalate'],
49
+ },
50
+
51
+ // ③ KNOWLEDGE — what must the agent KNOW before it acts, and where does it learn?
52
+ // (Discovery: "What facts gate the loop — what's the minimum the agent must
53
+ // have grounded before it's allowed to propose?")
54
+ // `requirements` are declarative gates scored from workspace state by the
55
+ // Cloudflare preset's KnowledgeStateAccessor (config-set or rows-exist rules).
56
+ // `sources` are what the acquisition loop may read. `loop` tunes the gate.
57
+ knowledge: {
58
+ sources: [
59
+ // Domain docs you drop in ./knowledge are read as `vault://` sources.
60
+ { uri: 'vault://knowledge', kind: 'vault' },
61
+ // Add web / regulation / integration sources the researcher may pull from.
62
+ // { uri: 'https://example.gov/regulation', kind: 'regulation' },
63
+ ],
64
+ requirements: [
65
+ {
66
+ id: 'workspace-profile-set',
67
+ description: 'The workspace has a configured business profile.',
68
+ category: 'company_specific',
69
+ acquisitionMode: 'ask_user',
70
+ importance: 'blocking',
71
+ freshness: 'static',
72
+ // Satisfied when a config dot-path is set on the workspace.
73
+ satisfiedBy: { config: 'profile.businessName', nonEmpty: true },
74
+ },
75
+ {
76
+ id: 'has-client-records',
77
+ description: 'At least one client/lead record exists to ground outreach.',
78
+ category: 'domain_specific',
79
+ acquisitionMode: 'query_connector',
80
+ importance: 'high',
81
+ freshness: 'daily',
82
+ // Satisfied when >= 1 row exists in a workspace-scoped table.
83
+ satisfiedBy: { table: 'knowledge', minRows: 1 },
84
+ },
85
+ ],
86
+ loop: {
87
+ goal: 'Ground every client-facing claim against a real record before proposing.',
88
+ minConfidence: 0.7,
89
+ freshness: 'session',
90
+ },
91
+ },
92
+
93
+ // ⑤ INTEGRATIONS — what systems does the agent read/write through?
94
+ // (Discovery: "Which CRMs / data sources / messaging channels does the
95
+ // workflow touch?") These are @tangle-network/agent-integrations catalog
96
+ // kinds. Reads run immediately; writes return approval-required → a proposal.
97
+ integrations: {
98
+ enabled: [
99
+ // 'salesforce',
100
+ // 'whatsapp',
101
+ ],
102
+ },
103
+
104
+ // UI — may the agent emit generated views (render_ui)? Optional.
105
+ ui: {
106
+ generatedUi: true,
107
+ },
108
+
109
+ // MODEL — omit to resolve from env (TANGLE_API_KEY) at boot via
110
+ // resolveTangleModelConfig. Pin here only if you need a fixed model.
111
+ })
112
+
113
+ export type Config = typeof config
File without changes
@@ -0,0 +1,24 @@
1
+ # knowledge/
2
+
3
+ Domain documents the agent grounds on. This directory is DATA — drop files here;
4
+ do not write code.
5
+
6
+ ## Walk this
7
+
8
+ 1. Drop your real domain docs in here as `.md` / `.txt` / `.json` (regulation,
9
+ product sheets, provider lists, playbooks). One topic per file. Subdirectories
10
+ are fine.
11
+ 2. Register external research sources (URLs, regulation feeds, integration refs)
12
+ in `agent.config.ts` under `knowledge.sources` — those are what the acquisition
13
+ loop reads on top of these local files.
14
+ 3. Run `pnpm knowledge:ingest` to enumerate inputs (DRY) and, once a model-backed
15
+ driver is wired, drive the acquisition loop (`--run`).
16
+
17
+ ## What gates what
18
+
19
+ - The files here + `knowledge.sources` feed the BUILD loop (acquire grounded
20
+ knowledge). See KNOWLEDGE.md.
21
+ - `knowledge.requirements` in `agent.config.ts` is the ACT gate — what the agent
22
+ must KNOW before it's allowed to propose. Scored from live workspace state.
23
+
24
+ Do not commit secrets. Knowledge is content, not credentials.
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ // knowledge:ingest — the build-loop entry (NOT the act-gate; see KNOWLEDGE.md).
3
+ //
4
+ // This runs in Node (it touches the filesystem), never on the Worker edge path.
5
+ // It enumerates the domain docs you dropped under ./knowledge and the research
6
+ // sources declared in agent.config.ts, then drives the knowledge acquisition
7
+ // loop. By default it runs in DRY mode — it reports what WOULD be ingested so you
8
+ // can verify the inputs before spending model calls. Wire a real model-backed
9
+ // driver/decider (see the createKnowledgeLoop block below) and pass --run to
10
+ // execute the loop.
11
+ //
12
+ // Why a script and not a route: agent-knowledge owns disk I/O over a KB `root`;
13
+ // the acquisition loop proposes knowledge pages that a confidence gate accepts or
14
+ // drops. Grounding (sources) is always recorded; a low-confidence PROPOSAL is
15
+ // dropped — propose, don't apply. Tune the gate in KNOWLEDGE.md.
16
+
17
+ import { readdir, readFile } from 'node:fs/promises'
18
+ import { join, dirname } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+ import { existsSync } from 'node:fs'
21
+
22
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
23
+ const KNOWLEDGE_DIR = join(ROOT, 'knowledge')
24
+ const RUN = process.argv.includes('--run')
25
+
26
+ async function listDocs(dir) {
27
+ if (!existsSync(dir)) return []
28
+ const out = []
29
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
30
+ if (entry.isDirectory()) out.push(...(await listDocs(join(dir, entry.name))))
31
+ else if (/\.(md|txt|json)$/i.test(entry.name)) out.push(join(dir, entry.name))
32
+ }
33
+ return out
34
+ }
35
+
36
+ // Read agent.config.ts as text to discover declared sources without a TS runtime.
37
+ async function readDeclaredSources() {
38
+ const cfgPath = join(ROOT, 'agent.config.ts')
39
+ if (!existsSync(cfgPath)) return []
40
+ const text = await readFile(cfgPath, 'utf8')
41
+ // Pull `{ uri: '…', kind: '…' }` literals out of the knowledge.sources block.
42
+ const sources = []
43
+ const re = /\{\s*uri:\s*['"]([^'"]+)['"](?:\s*,\s*kind:\s*['"]([^'"]+)['"])?\s*\}/g
44
+ let m
45
+ while ((m = re.exec(text))) sources.push({ uri: m[1], kind: m[2] ?? 'unknown' })
46
+ return sources
47
+ }
48
+
49
+ async function main() {
50
+ const docs = await listDocs(KNOWLEDGE_DIR)
51
+ const sources = await readDeclaredSources()
52
+
53
+ console.log('knowledge:ingest')
54
+ console.log(` local docs (./knowledge): ${docs.length}`)
55
+ for (const d of docs) console.log(` - ${d.slice(ROOT.length + 1)}`)
56
+ console.log(` declared sources (agent.config.ts): ${sources.length}`)
57
+ for (const s of sources) console.log(` - [${s.kind}] ${s.uri}`)
58
+
59
+ if (!RUN) {
60
+ console.log('')
61
+ console.log('DRY run. Pass --run to drive the acquisition loop.')
62
+ console.log('Wire a model-backed driver + decider first — see the commented block in this file and KNOWLEDGE.md.')
63
+ return
64
+ }
65
+
66
+ // To actually run the loop, install @tangle-network/agent-knowledge +
67
+ // @tangle-network/agent-runtime (peers), give the loop a KB `root` on disk and
68
+ // a model-backed driver, then:
69
+ //
70
+ // import { createKnowledgeLoop } from '@tangle-network/agent-app/knowledge-loop'
71
+ // import { config } from '../agent.config.ts' // via a TS loader / tsx
72
+ // const loop = createKnowledgeLoop(config.knowledge, {
73
+ // root: KNOWLEDGE_DIR,
74
+ // driver: async ({ systemPrompt, userMessage }) => ({ finalText: await callModel(systemPrompt, userMessage) }),
75
+ // defaultMinConfidence: config.knowledge.loop?.minConfidence ?? 0.7,
76
+ // })
77
+ // const result = await loop.run()
78
+ // console.log('applied:', result.applied)
79
+ //
80
+ throw new Error('--run requires a wired model-backed driver. See KNOWLEDGE.md before enabling.')
81
+ }
82
+
83
+ main().catch((err) => {
84
+ console.error(`knowledge:ingest: ${err.message}`)
85
+ process.exit(1)
86
+ })
@@ -0,0 +1,77 @@
1
+ /**
2
+ * src/agent-app.ts — the COMPOSER. Code, not data.
3
+ *
4
+ * Turns the declarative `agent.config.ts` + your Cloudflare bindings into the
5
+ * wired runtime surfaces:
6
+ * - tool handlers (the agent→app side channel) over the house D1 + KV preset,
7
+ * - the proposal taxonomy (from config.taxonomy — including the regulated set),
8
+ * - the knowledge gate accessor (scores config.knowledge.requirements from D1),
9
+ * - the resolved model config.
10
+ *
11
+ * This is the seam between DATA (config) and ENGINE (agent-app modules). You do
12
+ * NOT edit `@tangle-network/agent-app` to change behavior; you edit this file to
13
+ * compose its seams differently, or override a single handler. Default to the
14
+ * preset — drop down to a custom handler only when the house stack genuinely
15
+ * cannot express your persistence. See AGENTS.md "DATA vs CODE".
16
+ */
17
+
18
+ import { config } from '../agent.config'
19
+ import {
20
+ createPresetToolHandlers,
21
+ createD1KnowledgeStateAccessor,
22
+ type D1Like,
23
+ type VaultKv,
24
+ } from '@tangle-network/agent-app/preset-cloudflare'
25
+ import { resolveTangleModelConfig } from '@tangle-network/agent-app/runtime'
26
+ import {
27
+ buildKnowledgeRequirements,
28
+ deriveSignals,
29
+ } from '@tangle-network/agent-app/knowledge'
30
+ import type { AppToolHandlers, AppToolTaxonomy } from '@tangle-network/agent-app/tools'
31
+
32
+ /** The Cloudflare bindings the worker hands the composer. */
33
+ export interface AppBindings {
34
+ /** D1 database — satisfies the preset's structural {@link D1Like}. */
35
+ DB: D1Like
36
+ /** KV namespace used as the artifact vault — satisfies {@link VaultKv}. */
37
+ VAULT: VaultKv
38
+ }
39
+
40
+ export interface ComposedAgentApp {
41
+ handlers: AppToolHandlers
42
+ taxonomy: AppToolTaxonomy
43
+ /** Score the config's knowledge requirements against live workspace state. */
44
+ knowledgeGate: (workspaceId: string) => Promise<ReturnType<typeof buildKnowledgeRequirements>>
45
+ /** Resolve the model config from env. Lazy + fail-loud: composing the app does
46
+ * NOT require model env; only the chat path that actually streams does. */
47
+ resolveModel: () => ReturnType<typeof resolveTangleModelConfig>
48
+ }
49
+
50
+ /**
51
+ * Compose the agent app from config + bindings. No domain value is hard-coded:
52
+ * the taxonomy comes from `config.taxonomy`, the gate from
53
+ * `config.knowledge.requirements`. Swap a handler here if (and only if) the
54
+ * preset cannot express your persistence.
55
+ */
56
+ export function createAgentApp(bindings: AppBindings): ComposedAgentApp {
57
+ const handlers = createPresetToolHandlers({ db: bindings.DB, vault: bindings.VAULT })
58
+
59
+ const taxonomy: AppToolTaxonomy = {
60
+ proposalTypes: config.taxonomy.proposalTypes,
61
+ regulatedTypes: config.taxonomy.regulatedTypes,
62
+ }
63
+
64
+ async function knowledgeGate(workspaceId: string) {
65
+ const accessor = createD1KnowledgeStateAccessor({
66
+ db: bindings.DB,
67
+ workspaceId,
68
+ // Resolve workspace config however your app stores it; stub returns nothing
69
+ // until you wire real workspace config (see CUSTOMIZE.md ③).
70
+ config: () => undefined,
71
+ })
72
+ const signals = await deriveSignals(config.knowledge.requirements, accessor)
73
+ return buildKnowledgeRequirements(config.knowledge.requirements, signals)
74
+ }
75
+
76
+ return { handlers, taxonomy, knowledgeGate, resolveModel: () => resolveTangleModelConfig() }
77
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * src/worker.ts — the chat route. Code, not data.
3
+ *
4
+ * The minimal wired entry: a Cloudflare Worker that runs one bounded tool-loop
5
+ * turn per chat request, with the agent→app tool side channel wired to the
6
+ * preset handlers and the OpenAI-compatible Tangle Router as the backend.
7
+ *
8
+ * What this proves: the agent can call `submit_proposal` / `schedule_followup` /
9
+ * `render_ui` / `add_citation`, each call is validated against your
10
+ * `config.taxonomy` and persisted by the preset — with regulated proposals
11
+ * fail-closed to the approval queue (never auto-executed). You extend the route;
12
+ * you do not edit `@tangle-network/agent-app`.
13
+ *
14
+ * Replace the system-prompt assembly + per-turn context recovery with your real
15
+ * auth/session once you have one. Keep the human-in-the-loop invariant: regulated
16
+ * proposals stay proposals.
17
+ */
18
+
19
+ import { config } from '../agent.config'
20
+ import { createAgentApp, type AppBindings } from './agent-app'
21
+ import {
22
+ buildAppToolOpenAITools,
23
+ createAppToolRuntimeExecutor,
24
+ isAppToolName,
25
+ type AppToolContext,
26
+ } from '@tangle-network/agent-app/tools'
27
+ import {
28
+ runAppToolLoop,
29
+ createOpenAICompatStreamTurn,
30
+ type LoopEvent,
31
+ type LoopMessage,
32
+ type LoopToolCall,
33
+ type ToolLoopEvent,
34
+ type ToolLoopResult,
35
+ } from '@tangle-network/agent-app/runtime'
36
+
37
+ interface ChatRequest {
38
+ message: string
39
+ /** Trusted server-side in a real app; taken from the body here for the skeleton. */
40
+ userId?: string
41
+ workspaceId?: string
42
+ threadId?: string | null
43
+ }
44
+
45
+ /**
46
+ * The OpenAI-compat stream yields the app's rich `LoopEvent` (it adds UI-only
47
+ * `reasoning` / `usage` deltas). The awaitable loop consumes only `text` +
48
+ * `tool_call`, so narrow the rest onto the substrate's `other` channel. (The
49
+ * streaming `streamAppToolLoop` path takes the rich stream directly via
50
+ * `extractText` / `extractToolCall` and needs no narrowing.)
51
+ */
52
+ function narrowToToolLoopEvents(
53
+ streamTurn: (messages: LoopMessage[]) => AsyncIterable<LoopEvent>,
54
+ ): (messages: LoopMessage[]) => AsyncIterable<ToolLoopEvent> {
55
+ return (messages) =>
56
+ (async function* () {
57
+ for await (const ev of streamTurn(messages)) {
58
+ if (ev.type === 'text') yield { type: 'text', text: ev.text }
59
+ else if (ev.type === 'tool_call') yield { type: 'tool_call', call: ev.call }
60
+ else yield { type: 'other', event: ev }
61
+ }
62
+ })()
63
+ }
64
+
65
+ /** Assemble the system prompt from the config identity (DATA → prompt). */
66
+ function buildSystemPrompt(): string {
67
+ const fragments = [
68
+ config.identity.persona,
69
+ ...(config.identity.systemPromptFragments ?? []),
70
+ ...Object.values(config.identity.disclaimers ?? {}),
71
+ ]
72
+ return fragments.join('\n\n')
73
+ }
74
+
75
+ export default {
76
+ async fetch(request: Request, env: AppBindings): Promise<Response> {
77
+ const url = new URL(request.url)
78
+ if (url.pathname !== '/chat' || request.method !== 'POST') {
79
+ return new Response('Not found', { status: 404 })
80
+ }
81
+
82
+ const body = (await request.json()) as ChatRequest
83
+ if (!body.message) {
84
+ return new Response(JSON.stringify({ error: 'message is required' }), {
85
+ status: 400,
86
+ headers: { 'Content-Type': 'application/json' },
87
+ })
88
+ }
89
+
90
+ const app = createAgentApp(env)
91
+
92
+ // Trusted per-turn context. In production recover this from your auth/session,
93
+ // NEVER from model tool args — the model must not be able to forge identity.
94
+ const ctx: AppToolContext = {
95
+ userId: body.userId ?? 'anonymous',
96
+ workspaceId: body.workspaceId ?? 'default',
97
+ threadId: body.threadId ?? null,
98
+ }
99
+
100
+ const executor = createAppToolRuntimeExecutor({
101
+ handlers: app.handlers,
102
+ taxonomy: app.taxonomy,
103
+ ctx,
104
+ })
105
+
106
+ const tools = buildAppToolOpenAITools(app.taxonomy)
107
+
108
+ const result = await runAppToolLoop({
109
+ systemPrompt: buildSystemPrompt(),
110
+ userMessage: body.message,
111
+ streamTurn: narrowToToolLoopEvents(createOpenAICompatStreamTurn({ ...app.resolveModel(), tools })),
112
+ executeToolCall: (call: LoopToolCall) => executor({ toolName: call.toolName, args: call.args }),
113
+ isExecutableTool: isAppToolName,
114
+ })
115
+
116
+ return new Response(
117
+ JSON.stringify({
118
+ text: result.finalText,
119
+ toolResults: result.toolResults.map((t: ToolLoopResult['toolResults'][number]) => ({
120
+ label: t.label,
121
+ outcome: t.outcome,
122
+ })),
123
+ turns: result.turns,
124
+ }),
125
+ { headers: { 'Content-Type': 'application/json' } },
126
+ )
127
+ },
128
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { config } from '../agent.config'
3
+ import { createAgentApp } from '../src/agent-app'
4
+ import type { D1Like, VaultKv } from '@tangle-network/agent-app/preset-cloudflare'
5
+
6
+ // Tiny in-memory fakes for D1 + KV so the composer runs without Cloudflare.
7
+ function fakeDb(): D1Like {
8
+ return {
9
+ prepare() {
10
+ return {
11
+ bind() {
12
+ return this
13
+ },
14
+ async first<T>() {
15
+ return null as T | null
16
+ },
17
+ async run() {
18
+ return { success: true }
19
+ },
20
+ async all<T>() {
21
+ return { results: [] as T[] }
22
+ },
23
+ }
24
+ },
25
+ } as unknown as D1Like
26
+ }
27
+
28
+ function fakeVault(): VaultKv {
29
+ const store = new Map<string, string>()
30
+ return {
31
+ async get(key: string) {
32
+ return store.get(key) ?? null
33
+ },
34
+ async put(key: string, value: string) {
35
+ store.set(key, value)
36
+ },
37
+ } as unknown as VaultKv
38
+ }
39
+
40
+ describe('agent.config', () => {
41
+ it('regulatedTypes is a subset of proposalTypes (human-in-the-loop invariant)', () => {
42
+ for (const t of config.taxonomy.regulatedTypes) {
43
+ expect(config.taxonomy.proposalTypes).toContain(t)
44
+ }
45
+ })
46
+
47
+ it('has a non-empty identity persona', () => {
48
+ expect(config.identity.persona.length).toBeGreaterThan(0)
49
+ })
50
+ })
51
+
52
+ describe('createAgentApp', () => {
53
+ it('composes handlers + taxonomy from config without hard-coded domain values', () => {
54
+ const app = createAgentApp({ DB: fakeDb(), VAULT: fakeVault() })
55
+ expect(app.taxonomy.proposalTypes).toEqual(config.taxonomy.proposalTypes)
56
+ expect(typeof app.handlers.submitProposal).toBe('function')
57
+ expect(typeof app.knowledgeGate).toBe('function')
58
+ })
59
+
60
+ it('a submitted proposal is queued (regulated never auto-executes)', async () => {
61
+ const app = createAgentApp({ DB: fakeDb(), VAULT: fakeVault() })
62
+ const regulated = config.taxonomy.regulatedTypes[0]
63
+ if (!regulated) return
64
+ const r = await app.handlers.submitProposal(
65
+ { type: regulated, title: 'test', description: null },
66
+ { userId: 'u', workspaceId: 'w', threadId: null },
67
+ )
68
+ // The preset returns a pending proposal id; it is NOT an executed side effect.
69
+ expect(typeof r.proposalId).toBe('string')
70
+ })
71
+ })
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['tests/**/*.test.ts'],
6
+ },
7
+ })
@@ -0,0 +1,18 @@
1
+ # Copy to .dev.vars for local development (never commit .dev.vars).
2
+ # Production: `wrangler secret put <NAME>` for each secret.
3
+
4
+ # better-auth HMAC secret — generate one: openssl rand -base64 32
5
+ BETTER_AUTH_SECRET=REPLACE_WITH_RANDOM_SECRET
6
+
7
+ # Tangle Router key the harness bills model calls against.
8
+ TANGLE_API_KEY=
9
+ # Optional router override; omit for the platform default.
10
+ # TANGLE_ROUTER_URL=
11
+
12
+ # Sandbox gateway credentials — the agent runs in a Tangle sandbox. Without
13
+ # these every turn fails loud (there is no mock agent).
14
+ SANDBOX_API_KEY=
15
+ SANDBOX_GATEWAY_URL=
16
+
17
+ # Model override without a redeploy (falls back to config.model.default).
18
+ # MODEL_NAME=
@@ -0,0 +1,73 @@
1
+ # AGENTS.md — you are customizing a chat agent-app
2
+
3
+ You are a coding agent working in a project generated by `create-agent-app --chat`.
4
+ This project is a thin customization layer on top of `@tangle-network/agent-app`
5
+ (the shell): the whole server chat vertical — auth, thread/message persistence,
6
+ streaming turns with buffered replay, multimodal uploads, human-in-the-loop asks —
7
+ is ASSEMBLED from shell factories, not written here. Walk this contract before you
8
+ touch anything. It is a checklist, not prose — follow it in order.
9
+
10
+ ## 0. Orient
11
+
12
+ - [ ] Read this file end to end.
13
+ - [ ] Read `CUSTOMIZE.md` — the ordered fill-checklist. It is your task list.
14
+ - [ ] Run `pnpm install && pnpm typecheck && pnpm test`. Confirm green BEFORE editing.
15
+ The test suite includes the end-to-end turn gate (fake sandbox producer →
16
+ streamed turn → persisted transcript) — it proves the assembly, not stubs.
17
+
18
+ ## 1. The one rule (layering)
19
+
20
+ > You change behavior by editing DATA and the COMPOSER — never by editing
21
+ > `@tangle-network/agent-app`.
22
+
23
+ - The shell owns mechanism: turn protocol + hook order (`createChatTurnRoutes`
24
+ over agent-runtime's `handleChatTurn`), persistence columns (`createChatTables`),
25
+ the upload inline-vs-sandbox split, the interaction answer contract, auth guards.
26
+ - You own: `agent.config.ts` + `prompts/system.md` (DATA), `src/` (the COMPOSER +
27
+ routes), `migrations/` (must mirror the schema — the e2e test executes it),
28
+ `public/` (the dev page). That is the whole surface.
29
+
30
+ ## 2. DATA vs CODE — know which file you're in
31
+
32
+ - [ ] DATA → `agent.config.ts`: name, system prompt, model default + effort,
33
+ harness, renderable ask kinds. Plain values. If you're writing an `if`,
34
+ you're in the wrong file.
35
+ - [ ] DATA → `prompts/system.md`: the persona. State intents and hard rules,
36
+ never implementations — no shell commands, CLI flags, or install scripts.
37
+ The executing agent chooses tools at execution time.
38
+ - [ ] CODE → `src/chat.ts`: the COMPOSER. Wires config + env into the shell's
39
+ factories. Extend seams here (billing hooks, `transformFinalText`,
40
+ `onTurnComplete`); never re-implement what a factory already does.
41
+ - [ ] CODE → `src/sandbox.ts`: the sandbox lane (box naming, credentials,
42
+ profile). All agent intelligence lives IN the sandbox; this file only
43
+ reaches it.
44
+ - [ ] CODE → `src/worker.ts`: routing only. New endpoint = new handler in
45
+ `src/chat.ts`, one `if` here.
46
+
47
+ ## 3. Invariants — fail-closed, never relax
48
+
49
+ - [ ] TRUSTED CONTEXT: `userId`/`workspaceId` come from the better-auth SESSION,
50
+ never from a request body or model output. The `authorize` seam is the only
51
+ place identity is established — keep it that way.
52
+ - [ ] THREAD ACCESS: an inaccessible thread reads as 404, indistinguishable from
53
+ a missing one. A cross-workspace probe must not learn the thread exists.
54
+ - [ ] NO MOCK AGENT: without sandbox credentials a turn fails loud with a clear
55
+ error. Never add a canned-response fallback — a fake answer is worse than
56
+ an honest failure.
57
+ - [ ] AGENT-NATIVE: intelligence and tooling live in the sandboxed agent;
58
+ durability (rows, turn buffer) and money live here. If you're about to
59
+ parse the agent's prose for data, stop — that's a schema-validated tool's
60
+ job (see the shell's `/tools`).
61
+ - [ ] GROUNDING: the persona's fabrication rule stays. A real record or an
62
+ explicit "NOT ON FILE".
63
+
64
+ ## 4. Verify (every change ends here)
65
+
66
+ - [ ] `pnpm typecheck` — clean. Proves the assembly matches the shell contract.
67
+ - [ ] `pnpm test` — green. The e2e gate runs the REAL migration + REAL factories
68
+ with one fake (the sandbox event feed). If it fails, the app drifted from
69
+ the framework — fix the drift, not the test.
70
+ - [ ] For a real deploy: fill `wrangler.toml` + `.dev.vars`, run
71
+ `pnpm db:migrate:local`, then `pnpm dev` and exercise the dev page.
72
+
73
+ If any of these is red, you are not done. Do not weaken a test to pass.
@@ -0,0 +1,6 @@
1
+ # CLAUDE.md
2
+
3
+ The behavior contract for this project lives in `AGENTS.md`. Read it first, then
4
+ walk `CUSTOMIZE.md` (the fill-checklist).
5
+
6
+ @AGENTS.md