@intentius/chant-lexicon-fountain 0.37.2 → 0.38.0

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.
package/README.md CHANGED
@@ -43,11 +43,18 @@ just validate # check the generated artifacts
43
43
  just docs # build and serve the docs site
44
44
  ```
45
45
 
46
- The spec is a rolling endpoint with no release tag to pin, so generation
47
- falls back to a committed snapshot (`src/spec/fountain-openapi.snapshot.json`)
48
- when the live endpoint is unreachable. `chant dev coverage` compares the
49
- generated surface against that spec and reports properties upstream has
50
- added, plus the request schemas deliberately left unmodeled.
46
+ Generation is pinned to an upstream release artifact `FOUNTAIN_SPEC_VERSION`
47
+ in `src/spec/fetch.ts`. The same pin produces the same types on any machine,
48
+ so a regeneration that changes the surface means upstream moved, not that
49
+ someone's laptop was online.
50
+
51
+ Without a network it reads the committed snapshot
52
+ (`src/spec/fountain-openapi.snapshot.json`) instead. Either way it prints
53
+ which one it used and at what version.
54
+
55
+ `chant dev coverage` compares the generated surface against that spec and
56
+ reports properties upstream has added, plus the request schemas deliberately
57
+ left unmodeled.
51
58
 
52
59
  ## Project structure
53
60
 
@@ -1 +1 @@
1
- {"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/codegen/docs.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAkNH;;GAEG;AACH,wBAAsB,YAAY,CAAC,OAAO,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAyDjF"}
1
+ {"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/codegen/docs.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAqNH;;GAEG;AACH,wBAAsB,YAAY,CAAC,OAAO,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAyDjF"}
@@ -1 +1 @@
1
- {"version":3,"file":"package.d.ts","sourceRoot":"","sources":["../../src/codegen/package.ts"],"names":[],"mappings":"AAOA;;GAEG;AACH,wBAAsB,cAAc,CAAC,OAAO,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE;;;;;;;;;GAqBpF"}
1
+ {"version":3,"file":"package.d.ts","sourceRoot":"","sources":["../../src/codegen/package.ts"],"names":[],"mappings":"AAQA;;GAEG;AACH,wBAAsB,cAAc,CAAC,OAAO,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE;;;;;;;;;GAwBpF"}
@@ -5,8 +5,8 @@
5
5
  * (lifecycle flags, return attributes, extension constraints) that a
6
6
  * three-kind OpenAPI lexicon has no analog for — it would report 0% on
7
7
  * everything and mean nothing. What fountain actually needs to know is
8
- * whether its generated surface still matches upstream, because the spec
9
- * is a rolling endpoint with no release tag to pin (see spec/fetch.ts):
8
+ * whether its generated surface still matches upstream, measured against the
9
+ * pinned spec release (see spec/fetch.ts):
10
10
  *
11
11
  * 1. Property coverage — request-schema properties per modeled kind vs
12
12
  * what the committed surface baseline exposes. A gap means upstream
@@ -17,6 +17,22 @@
17
17
  */
18
18
  /** Request schemas with no typed resource, and why. */
19
19
  export declare const EXCLUDED_KINDS: Record<string, string>;
20
+ /**
21
+ * Upstream endpoints that exist but are absent from the OpenAPI spec, and the
22
+ * decision about each.
23
+ *
24
+ * `EXCLUDED_KINDS` cannot carry these. It is keyed on request-schema names and
25
+ * checked against the spec, so an entry for a schema upstream never publishes
26
+ * would match nothing, prove nothing, and quietly keep meaning nothing if the
27
+ * endpoint were later documented under a different name.
28
+ *
29
+ * These are the routes the coverage check is structurally blind to: fountain's
30
+ * spec is generated by OpenApiSpex from controller annotations, so an
31
+ * unannotated controller is invisible to every consumer of the spec, chant
32
+ * included. Recording them here is the only way an unmodeled kind stays a
33
+ * decision on record rather than something nobody noticed.
34
+ */
35
+ export declare const UNSPECIFIED_ENDPOINTS: Record<string, string>;
20
36
  export interface KindCoverage {
21
37
  kind: string;
22
38
  specProps: number;
@@ -34,6 +50,8 @@ export interface FountainCoverageReport {
34
50
  excludedKinds: Record<string, string>;
35
51
  /** Request schemas neither modeled nor on the exclusion list. */
36
52
  unaccountedKinds: string[];
53
+ /** Upstream endpoints the spec does not describe → the decision about each. */
54
+ unspecifiedEndpoints: Record<string, string>;
37
55
  /** Modeled properties as a percentage of upstream request properties. */
38
56
  overallPct: number;
39
57
  }
@@ -1 +1 @@
1
- {"version":3,"file":"coverage.d.ts","sourceRoot":"","sources":["../src/coverage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAQH,uDAAuD;AACvD,eAAO,MAAM,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAKjD,CAAC;AAEF,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,yEAAyE;IACzE,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB,8CAA8C;IAC9C,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,yDAAyD;IACzD,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,iEAAiE;IACjE,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,yEAAyE;IACzE,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,UAAU,eAAe;IACvB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;CAC7D;AAOD,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,MAAM,GAAG,MAAM,EACzB,OAAO,EAAE,eAAe,GACvB,sBAAsB,CAiDxB;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,sBAAsB,GAAG,MAAM,CAGpE;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,sBAAsB,GAAG,MAAM,CA0BpE;AAED,sDAAsD;AACtD,wBAAsB,uBAAuB,CAAC,IAAI,CAAC,EAAE;IACnD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAsBlC"}
1
+ {"version":3,"file":"coverage.d.ts","sourceRoot":"","sources":["../src/coverage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAQH,uDAAuD;AACvD,eAAO,MAAM,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAQjD,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,qBAAqB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAUxD,CAAC;AAEF,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,yEAAyE;IACzE,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB,8CAA8C;IAC9C,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,yDAAyD;IACzD,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,iEAAiE;IACjE,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,+EAA+E;IAC/E,oBAAoB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7C,yEAAyE;IACzE,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,UAAU,eAAe;IACvB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;CAC7D;AAOD,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,MAAM,GAAG,MAAM,EACzB,OAAO,EAAE,eAAe,GACvB,sBAAsB,CAkDxB;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,sBAAsB,GAAG,MAAM,CAGpE;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,sBAAsB,GAAG,MAAM,CAkCpE;AAED,sDAAsD;AACtD,wBAAsB,uBAAuB,CAAC,IAAI,CAAC,EAAE;IACnD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAsBlC"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "8569e09c57362cc71435cb5624e4519edf949b1937e47d6811f24e98be890e5f",
4
+ "manifest.json": "5c6136425fcb1037a1f6fa6aa10e4179943397ea0af87dadaf990f907e2574f3",
5
5
  "meta.json": "6666b7a77db9210a329219c6a5e107e5ae8794a34542f7310ce16d9bf95c64f5",
6
6
  "types/index.d.ts": "1dfdcba184fffcf464dac7d1d71ef7abed2ae02a8d638c0de50e0b0317b7eb01",
7
7
  "rules/ftn001-no-secret-literals.ts": "897a4ce1ec790b3c1540d32892603bd33ff4bf30eb6ba4cd2565dee356d4962e",
@@ -17,5 +17,5 @@
17
17
  "skills/chant-fountain-secrets.md": "27e349a91589510a92e518c7d7824a4a322cab5ef242cf5799373a55cbfcd1cd",
18
18
  "skills/chant-fountain-locked-sandboxes.md": "de82f06cb3a08ba6bf3ae45fb9869e21d6da18b9ebe0fc769da8aebaceea7dd1"
19
19
  },
20
- "composite": "f333a5d4538a8295578e0b6aed4baf37e9c61e117f0dcec35d72118c22406c00"
20
+ "composite": "f1a2edc658cd070b0f65d9fafa6c76878e797bf3e14c64b2f773e785fb5b5d08"
21
21
  }
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "fountain",
3
- "version": "0.37.2",
3
+ "version": "0.38.0",
4
4
  "chantVersion": ">=0.1.0",
5
- "namespace": "Fountain"
5
+ "namespace": "Fountain",
6
+ "specVersion": "v0.3.0"
6
7
  }
@@ -1 +1 @@
1
- {"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAc,MAAM,kBAAkB,CAAC;AA2B/D,eAAO,MAAM,kBAAkB,EAAE,UAgDhC,CAAC"}
1
+ {"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAc,MAAM,kBAAkB,CAAC;AAyC/D,eAAO,MAAM,kBAAkB,EAAE,UAmDhC,CAAC"}
@@ -1,26 +1,35 @@
1
1
  /**
2
2
  * Fetch the fountain OpenAPI spec.
3
3
  *
4
- * Fountain serves its OpenAPI 3.1 spec from the running app at
5
- * /api/openapi.json (generated from code via OpenApiSpex; public, no auth).
6
- * This is an unversioned live endpoint: it always serves the spec of
7
- * whatever is currently deployed, with no release tag to pin — fountain
8
- * follows the rolling-spec model (like fly). A versioned spec artifact per
9
- * release shipped upstream as a release asset (BinaryBourbon/fountain#140);
10
- * when the hosted endpoint is reliably current this fetcher can prefer it.
4
+ * Pinned to a release artifact, not a live endpoint. Upstream ships
5
+ * `openapi.json` as a release asset (BinaryBourbon/fountain#147), so there is
6
+ * a tag to pin and generation is reproducible: same pin in, same generated
7
+ * surface out, on any machine, with or without a network.
11
8
  *
12
- * Fallback: a committed snapshot (fountain-openapi.snapshot.json) keeps
13
- * generation hermetic when the live endpoint is unreachable CI runs,
14
- * offline dev, or the current state where the hosted instance predates the
15
- * endpoint. The snapshot is refreshed alongside surface.snapshot.json when
16
- * upstream moves (regenerate from a fountain checkout via
17
- * `mix openapi.spec.json --spec FountainWeb.ApiSpec`).
9
+ * It used to fetch `/api/openapi.json` from a running instance. That made the
10
+ * generated types a function of whatever a particular server happened to be
11
+ * serving at the moment someone ran `npm run generate` two runs a week apart
12
+ * could differ with no diff in chant to explain it. Every other lexicon pins
13
+ * its upstream spec (AWS a CloudFormation zip, each k8s CRD an operator
14
+ * release); this one no longer is the exception.
15
+ *
16
+ * The committed snapshot is now only the offline path. It is not a silent
17
+ * substitute for the pin: both routes log which one was taken and at what
18
+ * version, because a fallback nobody can see is how you end up debugging types
19
+ * that came from somewhere you did not expect.
20
+ */
21
+ /**
22
+ * The pinned upstream spec.
23
+ *
24
+ * v0.3.0 is the first release to carry the `openapi.json` asset. Bumping this
25
+ * changes the generated surface, so it belongs in its own commit with the
26
+ * regenerated snapshot beside it.
18
27
  */
28
+ export declare const FOUNTAIN_SPEC_VERSION = "v0.3.0";
19
29
  /**
20
- * Fetch the fountain OpenAPI spec with caching, falling back to the
21
- * committed snapshot when the live endpoint is unreachable. Returns a
22
- * single-entry map — the whole spec is one document; the parser fans it
23
- * out into per-kind results.
30
+ * Fetch the pinned fountain OpenAPI spec, falling back to the committed
31
+ * snapshot when there is no network. Returns a single-entry map — the whole
32
+ * spec is one document; the parser fans it out into per-kind results.
24
33
  */
25
34
  export declare function fetchSchemas(options?: {
26
35
  force?: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"fetch.d.ts","sourceRoot":"","sources":["../../src/spec/fetch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAcH;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,OAAO,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAe9F"}
1
+ {"version":3,"file":"fetch.d.ts","sourceRoot":"","sources":["../../src/spec/fetch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAQH;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,WAAW,CAAC;AAQ9C;;;;GAIG;AACH,wBAAsB,YAAY,CAAC,OAAO,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAiB9F"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-fountain",
3
- "version": "0.37.2",
3
+ "version": "0.38.0",
4
4
  "type": "module",
5
5
  "description": "Fountain lexicon for chant — sandboxed agent environments, vaults, and agents as typed estate",
6
6
  "license": "Apache-2.0",
@@ -50,7 +50,7 @@
50
50
  "bundle": "tsx src/package-cli.ts"
51
51
  },
52
52
  "peerDependencies": {
53
- "@intentius/chant": "^0.37.2",
53
+ "@intentius/chant": "^0.38.0",
54
54
  "typescript": "^5.9.3"
55
55
  },
56
56
  "devDependencies": {
@@ -67,12 +67,15 @@ kind: Environment
67
67
  metadata:
68
68
  name: team-env
69
69
  spec:
70
+ name: team-env
70
71
  networking_type: limited
71
72
  networking_config:
72
73
  allowed_hosts:
73
74
  - github.com
74
75
  \`\`\`
75
76
 
77
+ \`metadata.name\` is the resource's declared \`name\`, not the name of the variable you exported it as. fountain reconciles by that name, so renaming the variable does not orphan the resource. An entity declared without a \`name\` falls back to the export name.
78
+
76
79
  The output is ejectable — \`fountain apply -f\` accepts it verbatim, so adopting chant here does not trap the manifests behind chant.
77
80
 
78
81
  ## Applying the manifest
@@ -4,6 +4,7 @@ import { fountainPlugin } from "../plugin";
4
4
  import { dirname, join } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
  import { readFileSync } from "fs";
7
+ import { FOUNTAIN_SPEC_VERSION } from "../spec/fetch";
7
8
 
8
9
  /**
9
10
  * Package the fountain lexicon for distribution.
@@ -21,6 +22,9 @@ export async function packageLexicon(options?: { verbose?: boolean; force?: bool
21
22
  version: pkgJson.version ?? "0.0.0",
22
23
  chantVersion: ">=0.1.0",
23
24
  namespace: "Fountain",
25
+ // Which upstream API this surface was generated from. Without it a built
26
+ // lexicon cannot say what it was built against.
27
+ specVersion: FOUNTAIN_SPEC_VERSION,
24
28
  }),
25
29
  srcDir,
26
30
  collectSkills: () => collectSkills(fountainPlugin.skills?.() ?? []),
@@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest";
2
2
  import { readFileSync } from "fs";
3
3
  import { join, dirname } from "path";
4
4
  import { fileURLToPath } from "url";
5
- import { computeFountainCoverage, formatVerbose, EXCLUDED_KINDS } from "./coverage";
5
+ import { computeFountainCoverage, formatVerbose, EXCLUDED_KINDS, UNSPECIFIED_ENDPOINTS } from "./coverage";
6
+ import { fetchSchemas } from "./spec/fetch";
6
7
 
7
8
  const srcDir = dirname(fileURLToPath(import.meta.url));
8
9
  const spec = readFileSync(join(srcDir, "spec", "fountain-openapi.snapshot.json"), "utf-8");
@@ -61,3 +62,26 @@ describe("fountain coverage", () => {
61
62
  }
62
63
  });
63
64
  });
65
+
66
+ describe("unspecified upstream endpoints", () => {
67
+ it("records a decision for every endpoint the spec cannot describe", () => {
68
+ // The point of the list is the reasoning, not the route string. An entry
69
+ // with an empty or throwaway reason is the omission it exists to prevent.
70
+ for (const [route, reason] of Object.entries(UNSPECIFIED_ENDPOINTS)) {
71
+ expect(route.startsWith("/"), `${route} should be a route path`).toBe(true);
72
+ expect(reason.length, `${route} needs a real reason`).toBeGreaterThan(40);
73
+ }
74
+ });
75
+
76
+ it("does not duplicate anything the spec already covers", async () => {
77
+ // If upstream annotates one of these controllers, the endpoint becomes
78
+ // visible to EXCLUDED_KINDS/unaccountedKinds and this list should shrink
79
+ // rather than shadow it.
80
+ const specs = await fetchSchemas();
81
+ const raw = specs.get("fountain-openapi.json");
82
+ const paths = Object.keys(JSON.parse(raw!.toString("utf-8")).paths ?? {});
83
+ for (const route of Object.keys(UNSPECIFIED_ENDPOINTS)) {
84
+ expect(paths.some((p) => p.endsWith(route))).toBe(false);
85
+ }
86
+ });
87
+ });
package/src/coverage.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * (lifecycle flags, return attributes, extension constraints) that a
6
6
  * three-kind OpenAPI lexicon has no analog for — it would report 0% on
7
7
  * everything and mean nothing. What fountain actually needs to know is
8
- * whether its generated surface still matches upstream, because the spec
9
- * is a rolling endpoint with no release tag to pin (see spec/fetch.ts):
8
+ * whether its generated surface still matches upstream, measured against the
9
+ * pinned spec release (see spec/fetch.ts):
10
10
  *
11
11
  * 1. Property coverage — request-schema properties per modeled kind vs
12
12
  * what the committed surface baseline exposes. A gap means upstream
@@ -28,6 +28,36 @@ export const EXCLUDED_KINDS: Record<string, string> = {
28
28
  PromptRequest: "turn-level input inside a conversation run",
29
29
  SecretRequest: "secrets are a write-only sub-resource — upserted by fountainApply",
30
30
  VaultSecretRequest: "secrets are a write-only sub-resource — upserted by fountainApply",
31
+ ApplyRequest:
32
+ "the envelope fountainApply builds around a manifest, not a thing anyone declares — " +
33
+ "its contents are the Environment/Vault/Agent resources, which are modeled",
34
+ };
35
+
36
+ /**
37
+ * Upstream endpoints that exist but are absent from the OpenAPI spec, and the
38
+ * decision about each.
39
+ *
40
+ * `EXCLUDED_KINDS` cannot carry these. It is keyed on request-schema names and
41
+ * checked against the spec, so an entry for a schema upstream never publishes
42
+ * would match nothing, prove nothing, and quietly keep meaning nothing if the
43
+ * endpoint were later documented under a different name.
44
+ *
45
+ * These are the routes the coverage check is structurally blind to: fountain's
46
+ * spec is generated by OpenApiSpex from controller annotations, so an
47
+ * unannotated controller is invisible to every consumer of the spec, chant
48
+ * included. Recording them here is the only way an unmodeled kind stays a
49
+ * decision on record rather than something nobody noticed.
50
+ */
51
+ export const UNSPECIFIED_ENDPOINTS: Record<string, string> = {
52
+ "/api-keys": [
53
+ "not modeled, and not modelable as a declarable: an API key's value is",
54
+ "returned once at creation and never readable again. A declared ApiKey could",
55
+ "be created but never diffed or reconciled — chant would report it",
56
+ "permanently unobservable, or recreate it on every apply and hand back a new",
57
+ "secret each time. Same write-only property that excludes SecretRequest.",
58
+ "If minting a key from chant is wanted, the honest shape is an op alongside",
59
+ "fountainRun: hand it to the caller once, never claim it as estate.",
60
+ ].join(" "),
31
61
  };
32
62
 
33
63
  export interface KindCoverage {
@@ -48,6 +78,8 @@ export interface FountainCoverageReport {
48
78
  excludedKinds: Record<string, string>;
49
79
  /** Request schemas neither modeled nor on the exclusion list. */
50
80
  unaccountedKinds: string[];
81
+ /** Upstream endpoints the spec does not describe → the decision about each. */
82
+ unspecifiedEndpoints: Record<string, string>;
51
83
  /** Modeled properties as a percentage of upstream request properties. */
52
84
  overallPct: number;
53
85
  }
@@ -111,6 +143,7 @@ export function computeFountainCoverage(
111
143
  modeledKinds,
112
144
  excludedKinds: EXCLUDED_KINDS,
113
145
  unaccountedKinds,
146
+ unspecifiedEndpoints: UNSPECIFIED_ENDPOINTS,
114
147
  overallPct: totalSpec === 0 ? 0 : Math.round((totalModeled / totalSpec) * 100),
115
148
  };
116
149
  }
@@ -138,6 +171,14 @@ export function formatVerbose(report: FountainCoverageReport): string {
138
171
  lines.push(` ${name} — ${reason}`);
139
172
  }
140
173
 
174
+ const unspecified = Object.entries(report.unspecifiedEndpoints);
175
+ if (unspecified.length > 0) {
176
+ lines.push("", "Upstream endpoints absent from the spec (coverage cannot see these):");
177
+ for (const [route, reason] of unspecified) {
178
+ lines.push(` ${route} — ${reason}`);
179
+ }
180
+ }
181
+
141
182
  if (report.unaccountedKinds.length > 0) {
142
183
  lines.push(
143
184
  "",
@@ -157,6 +157,8 @@ describe("fountain describeResources", () => {
157
157
 
158
158
  describeObservationConformance({
159
159
  lexicon: "fountain",
160
+ // No marker channel: every verdict must be `unknown` (#1348).
161
+ ownershipChannel: undefined,
160
162
  scenarios: [
161
163
  {
162
164
  name: "mixed present/absent/foreign against a live listing",
@@ -35,7 +35,9 @@ describe("fountain serializer", () => {
35
35
 
36
36
  expect(out).toContain("apiVersion: fountain.dev/v1");
37
37
  expect(out).toContain("kind: Environment");
38
- expect(out).toContain("name: conciergeEnv");
38
+ // The declared name, not the export name — fountain upserts by this.
39
+ expect(out).toContain("name: concierge-env");
40
+ expect(out).not.toContain("name: conciergeEnv");
39
41
  expect(out).toContain("networking_type: limited");
40
42
  expect(out).toContain("allowed_hosts:");
41
43
  expect(out).toContain("- github.com");
@@ -73,7 +75,15 @@ describe("fountain serializer", () => {
73
75
  ]),
74
76
  );
75
77
 
76
- expect(out).toContain("environment: conciergeEnv");
78
+ // Resolves to the referenced entity's fountain name, so the reference and
79
+ // the environment's own manifest agree on one identity.
80
+ expect(out).toContain("environment: concierge-env");
81
+ });
82
+
83
+ it("falls back to the export name when no name is declared", () => {
84
+ const vault = entity("Fountain::V1::Vault", { description: "no name here" });
85
+ const out = fountainSerializer.serialize(new Map([["stagingCreds", vault]]));
86
+ expect(out).toContain("name: stagingCreds");
77
87
  });
78
88
 
79
89
  it("quotes YAML-ambiguous strings", () => {
package/src/serializer.ts CHANGED
@@ -19,6 +19,20 @@ import { propsOf } from "./entity-props";
19
19
 
20
20
  const API_VERSION = "fountain.dev/v1";
21
21
 
22
+ /**
23
+ * The name fountain reconciles by.
24
+ *
25
+ * The declared `name` when there is one, the chant export name otherwise. This
26
+ * matters more than it looks: fountain upserts by name, so keying on the export
27
+ * name would mean renaming a TypeScript variable creates a second resource and
28
+ * orphans the first, and a declared `name` would be silently demoted to an
29
+ * ordinary attribute. Same rule the k8s serializer follows for `metadata.name`.
30
+ */
31
+ function fountainName(exportName: string, entity: Declarable): string {
32
+ const declared = propsOf(entity).name;
33
+ return typeof declared === "string" && declared.length > 0 ? declared : exportName;
34
+ }
35
+
22
36
  /** Fountain::V1::Agent → Agent */
23
37
  function kindOf(entityType: string): string {
24
38
  const parts = entityType.split("::");
@@ -30,10 +44,13 @@ export const fountainSerializer: Serializer = {
30
44
  rulePrefix: "FTN",
31
45
 
32
46
  serialize(entities: Map<string, Declarable>): string {
33
- // Reverse map for reference resolution: Declarable instance → name.
47
+ // Reverse map for reference resolution: Declarable instance → fountain name.
48
+ // Keyed on the fountain name rather than the export name so an agent's
49
+ // `environment` reference resolves to the same identity the environment's
50
+ // own manifest carries.
34
51
  const entityNames = new Map<Declarable, string>();
35
52
  for (const [name, entity] of entities) {
36
- entityNames.set(entity, name);
53
+ entityNames.set(entity, fountainName(name, entity));
37
54
  }
38
55
 
39
56
  const visitor: SerializerVisitor = {
@@ -65,7 +82,7 @@ export const fountainSerializer: Serializer = {
65
82
  const manifest = {
66
83
  apiVersion: API_VERSION,
67
84
  kind: kindOf(entity.entityType),
68
- metadata: { name },
85
+ metadata: { name: fountainName(name, entity) },
69
86
  spec,
70
87
  };
71
88
  docs.push(toYaml(manifest));
package/src/spec/fetch.ts CHANGED
@@ -1,20 +1,22 @@
1
1
  /**
2
2
  * Fetch the fountain OpenAPI spec.
3
3
  *
4
- * Fountain serves its OpenAPI 3.1 spec from the running app at
5
- * /api/openapi.json (generated from code via OpenApiSpex; public, no auth).
6
- * This is an unversioned live endpoint: it always serves the spec of
7
- * whatever is currently deployed, with no release tag to pin — fountain
8
- * follows the rolling-spec model (like fly). A versioned spec artifact per
9
- * release shipped upstream as a release asset (BinaryBourbon/fountain#140);
10
- * when the hosted endpoint is reliably current this fetcher can prefer it.
4
+ * Pinned to a release artifact, not a live endpoint. Upstream ships
5
+ * `openapi.json` as a release asset (BinaryBourbon/fountain#147), so there is
6
+ * a tag to pin and generation is reproducible: same pin in, same generated
7
+ * surface out, on any machine, with or without a network.
11
8
  *
12
- * Fallback: a committed snapshot (fountain-openapi.snapshot.json) keeps
13
- * generation hermetic when the live endpoint is unreachable CI runs,
14
- * offline dev, or the current state where the hosted instance predates the
15
- * endpoint. The snapshot is refreshed alongside surface.snapshot.json when
16
- * upstream moves (regenerate from a fountain checkout via
17
- * `mix openapi.spec.json --spec FountainWeb.ApiSpec`).
9
+ * It used to fetch `/api/openapi.json` from a running instance. That made the
10
+ * generated types a function of whatever a particular server happened to be
11
+ * serving at the moment someone ran `npm run generate` two runs a week apart
12
+ * could differ with no diff in chant to explain it. Every other lexicon pins
13
+ * its upstream spec (AWS a CloudFormation zip, each k8s CRD an operator
14
+ * release); this one no longer is the exception.
15
+ *
16
+ * The committed snapshot is now only the offline path. It is not a silent
17
+ * substitute for the pin: both routes log which one was taken and at what
18
+ * version, because a fallback nobody can see is how you end up debugging types
19
+ * that came from somewhere you did not expect.
18
20
  */
19
21
 
20
22
  import { join, dirname } from "path";
@@ -23,29 +25,39 @@ import { readFileSync } from "fs";
23
25
  import { fileURLToPath } from "url";
24
26
  import { fetchWithCache } from "@intentius/chant/codegen/fetch";
25
27
 
26
- const SCHEMA_URL = "https://fountain.inevitable.fyi/api/openapi.json";
28
+ /**
29
+ * The pinned upstream spec.
30
+ *
31
+ * v0.3.0 is the first release to carry the `openapi.json` asset. Bumping this
32
+ * changes the generated surface, so it belongs in its own commit with the
33
+ * regenerated snapshot beside it.
34
+ */
35
+ export const FOUNTAIN_SPEC_VERSION = "v0.3.0";
36
+
37
+ const SCHEMA_URL = `https://github.com/BinaryBourbon/fountain/releases/download/${FOUNTAIN_SPEC_VERSION}/openapi.json`;
27
38
 
28
- const CACHE_FILE = join(homedir(), ".chant", "fountain-openapi.json");
39
+ const CACHE_FILE = join(homedir(), ".chant", `fountain-openapi-${FOUNTAIN_SPEC_VERSION}.json`);
29
40
 
30
41
  const SNAPSHOT_FILE = join(dirname(fileURLToPath(import.meta.url)), "fountain-openapi.snapshot.json");
31
42
 
32
43
  /**
33
- * Fetch the fountain OpenAPI spec with caching, falling back to the
34
- * committed snapshot when the live endpoint is unreachable. Returns a
35
- * single-entry map — the whole spec is one document; the parser fans it
36
- * out into per-kind results.
44
+ * Fetch the pinned fountain OpenAPI spec, falling back to the committed
45
+ * snapshot when there is no network. Returns a single-entry map — the whole
46
+ * spec is one document; the parser fans it out into per-kind results.
37
47
  */
38
48
  export async function fetchSchemas(options?: { force?: boolean }): Promise<Map<string, Buffer>> {
39
49
  let raw: Buffer;
40
50
  try {
41
51
  raw = await fetchWithCache({ url: SCHEMA_URL, cacheFile: CACHE_FILE }, options?.force);
42
- // The live host currently 404s (deploy lag) a non-spec body must not
43
- // silently replace the snapshot.
52
+ // A 404 body is still a body. Parsing proves we got a spec and not an
53
+ // error page that would otherwise be cached and generated from.
44
54
  JSON.parse(raw.toString("utf-8"));
55
+ console.error(`[fountain] spec: pinned release ${FOUNTAIN_SPEC_VERSION}`);
45
56
  } catch (err) {
46
57
  console.error(
47
- `[fountain] live spec fetch failed (${err instanceof Error ? err.message.split("\n")[0] : err}) ` +
48
- `using committed snapshot`,
58
+ `[fountain] spec: committed snapshot could not fetch pinned release ${FOUNTAIN_SPEC_VERSION} ` +
59
+ `(${err instanceof Error ? err.message.split("\n")[0] : err}). ` +
60
+ `Refresh it with \`npm run generate -- --force\` on a networked machine.`,
49
61
  );
50
62
  raw = readFileSync(SNAPSHOT_FILE);
51
63
  }
@@ -1 +1 @@
1
- {"components":{"responses":{},"schemas":{"SecretListResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/Secret"},"type":"array","x-struct":null,"x-validate":null}},"required":["data"],"title":"SecretListResponse","type":"object","x-struct":"Elixir.FountainWeb.Schemas.SecretListResponse","x-validate":null},"ConversationCreateRequest":{"properties":{"agent_id":{"format":"uuid","type":"string","x-struct":null,"x-validate":null},"images":{"description":"Optional images to attach to the initial prompt.","items":{"$ref":"#/components/schemas/ImageInput"},"nullable":true,"type":"array","x-struct":null,"x-validate":null},"prompt":{"description":"Optional first turn prompt.","type":"string","x-struct":null,"x-validate":null},"sprite_name":{"description":"Override the auto-generated sprite name.","type":"string","x-struct":null,"x-validate":null},"vault_id":{"description":"Optional vault whose secrets override the environment's baseline at sprite spawn. Must satisfy the agent's allowed_vault_ids when that allowlist is set.","format":"uuid","nullable":true,"type":"string","x-struct":null,"x-validate":null}},"required":["agent_id"],"title":"ConversationCreateRequest","type":"object","x-struct":"Elixir.FountainWeb.Schemas.ConversationCreateRequest","x-validate":null},"Secret":{"description":"A named secret. Values are write-only — the API never returns them.","properties":{"environment_id":{"format":"uuid","type":"string","x-struct":null,"x-validate":null},"id":{"format":"uuid","type":"string","x-struct":null,"x-validate":null},"inserted_at":{"format":"date-time","type":"string","x-struct":null,"x-validate":null},"key":{"type":"string","x-struct":null,"x-validate":null},"updated_at":{"format":"date-time","type":"string","x-struct":null,"x-validate":null}},"required":["id","key","environment_id"],"title":"Secret","type":"object","x-struct":"Elixir.FountainWeb.Schemas.Secret","x-validate":null},"VaultUpdate":{"properties":{"description":{"type":"string","x-struct":null,"x-validate":null},"metadata":{"additionalProperties":true,"type":"object","x-struct":null,"x-validate":null},"name":{"maxLength":200,"minLength":1,"type":"string","x-struct":null,"x-validate":null}},"title":"VaultUpdate","type":"object","x-struct":"Elixir.FountainWeb.Schemas.VaultUpdate","x-validate":null},"ImageInput":{"description":"A base64-encoded image to attach to a prompt.","properties":{"data":{"description":"Base64-encoded image bytes.","type":"string","x-struct":null,"x-validate":null},"media_type":{"description":"MIME type of the image.","enum":["image/png","image/jpeg","image/gif","image/webp"],"type":"string","x-struct":null,"x-validate":null}},"required":["data","media_type"],"title":"ImageInput","type":"object","x-struct":"Elixir.FountainWeb.Schemas.ImageInput","x-validate":null},"Repository":{"properties":{"mount_path":{"pattern":"^/","type":"string","x-struct":null,"x-validate":null},"url":{"format":"uri","pattern":"^https://","type":"string","x-struct":null,"x-validate":null}},"required":["url","mount_path"],"title":"Repository","type":"object","x-struct":"Elixir.FountainWeb.Schemas.Repository","x-validate":null},"EnvironmentUpdate":{"properties":{"env_vars":{"additionalProperties":{"type":"string","x-struct":null,"x-validate":null},"type":"object","x-struct":null,"x-validate":null},"metadata":{"additionalProperties":true,"type":"object","x-struct":null,"x-validate":null},"name":{"maxLength":200,"minLength":1,"type":"string","x-struct":null,"x-validate":null},"networking_config":{"additionalProperties":true,"description":"Refines networking_type: limited. allowed_hosts is the only key honored today; unknown keys are ignored. Under limited, egress is restricted to the allowlisted domains. With no allowed_hosts (or an empty list), the sandbox denies all egress by default — this is a deny-all, not an allow-all.","properties":{"allowed_hosts":{"description":"Domains the sandbox may reach when networking_type is limited.","items":{"type":"string","x-struct":null,"x-validate":null},"type":"array","x-struct":null,"x-validate":null}},"type":"object","x-struct":null,"x-validate":null},"networking_type":{"enum":["unrestricted","limited"],"type":"string","x-struct":null,"x-validate":null},"packages":{"additionalProperties":true,"type":"object","x-struct":null,"x-validate":null},"repositories":{"items":{"$ref":"#/components/schemas/Repository"},"type":"array","x-struct":null,"x-validate":null},"setup_script":{"type":"string","x-struct":null,"x-validate":null}},"title":"EnvironmentUpdate","type":"object","x-struct":"Elixir.FountainWeb.Schemas.EnvironmentUpdate","x-validate":null},"VaultSecret":{"description":"A named secret in a vault. Values are write-only — the API never returns them.","properties":{"id":{"format":"uuid","type":"string","x-struct":null,"x-validate":null},"inserted_at":{"format":"date-time","type":"string","x-struct":null,"x-validate":null},"key":{"type":"string","x-struct":null,"x-validate":null},"updated_at":{"format":"date-time","type":"string","x-struct":null,"x-validate":null},"vault_id":{"format":"uuid","type":"string","x-struct":null,"x-validate":null}},"required":["id","key","vault_id"],"title":"VaultSecret","type":"object","x-struct":"Elixir.FountainWeb.Schemas.VaultSecret","x-validate":null},"TurnListResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/Turn"},"type":"array","x-struct":null,"x-validate":null}},"required":["data"],"title":"TurnListResponse","type":"object","x-struct":"Elixir.FountainWeb.Schemas.TurnListResponse","x-validate":null},"Sandbox":{"description":"One sprite lifespan owned by a conversation.","properties":{"id":{"format":"uuid","type":"string","x-struct":null,"x-validate":null},"sprite_name":{"type":"string","x-struct":null,"x-validate":null},"status":{"enum":["pending","starting","ready","terminated","failed"],"type":"string","x-struct":null,"x-validate":null}},"required":["id","sprite_name","status"],"title":"Sandbox","type":"object","x-struct":"Elixir.FountainWeb.Schemas.Sandbox","x-validate":null},"Error":{"properties":{"error":{"type":"string","x-struct":null,"x-validate":null}},"required":["error"],"title":"Error","type":"object","x-struct":"Elixir.FountainWeb.Schemas.Error","x-validate":null},"VaultSecretResponse":{"properties":{"data":{"$ref":"#/components/schemas/VaultSecret"}},"required":["data"],"title":"VaultSecretResponse","type":"object","x-struct":"Elixir.FountainWeb.Schemas.VaultSecretResponse","x-validate":null},"EnvironmentResponse":{"properties":{"data":{"$ref":"#/components/schemas/Environment"}},"required":["data"],"title":"EnvironmentResponse","type":"object","x-struct":"Elixir.FountainWeb.Schemas.EnvironmentResponse","x-validate":null},"ChangesetError":{"description":"Validation errors keyed by field, with each value an array of messages.","properties":{"errors":{"additionalProperties":{"items":{"type":"string","x-struct":null,"x-validate":null},"type":"array","x-struct":null,"x-validate":null},"type":"object","x-struct":null,"x-validate":null}},"required":["errors"],"title":"ChangesetError","type":"object","x-struct":"Elixir.FountainWeb.Schemas.ChangesetError","x-validate":null},"Agent":{"description":"An AI agent definition: runtime, model, skills, MCP, env.","properties":{"allowed_vault_ids":{"description":"Vaults a conversation may attach to this agent. null (default) allows any vault the tenant owns; an empty list forbids attaching any vault; a non-empty list is an allowlist. Vault values override the agent's environment on key collision, so this scopes who can override reviewed config.","items":{"format":"uuid","type":"string","x-struct":null,"x-validate":null},"nullable":true,"type":"array","x-struct":null,"x-validate":null},"description":{"type":"string","x-struct":null,"x-validate":null},"environment_id":{"format":"uuid","nullable":true,"type":"string","x-struct":null,"x-validate":null},"id":{"format":"uuid","type":"string","x-struct":null,"x-validate":null},"inserted_at":{"format":"date-time","type":"string","x-struct":null,"x-validate":null},"mcp_servers":{"additionalProperties":true,"type":"object","x-struct":null,"x-validate":null},"metadata":{"additionalProperties":true,"type":"object","x-struct":null,"x-validate":null},"model":{"description":"Canonical provider/model_id (e.g. anthropic/claude-sonnet-4-6).","pattern":"^[a-z0-9_-]+/[a-z0-9._-]+$","type":"string","x-struct":null,"x-validate":null},"name":{"type":"string","x-struct":null,"x-validate":null},"runtime":{"enum":["claude","codex","gemini","opencode"],"type":"string","x-struct":null,"x-validate":null},"skills":{"description":"Each entry is either inline (`{name, content}` — full SKILL.md text written to the sprite) or github (`{source, ref?, name?}` — installed on the sprite via the skills.sh CLI, optionally pinned to a tag/branch/sha via `ref`). Exactly one of `content` or `source` must be set on each entry.","items":{"properties":{"content":{"description":"Full SKILL.md body for inline entries.","type":"string","x-struct":null,"x-validate":null},"name":{"description":"Skill name (required for inline entries).","type":"string","x-struct":null,"x-validate":null},"ref":{"description":"Optional tag, branch, or sha pinning a github-sourced skill (installed as `owner/repo@ref`). Without it the default branch is fetched at spawn time.","pattern":"^[A-Za-z0-9._/-]+$","type":"string","x-struct":null,"x-validate":null},"source":{"description":"GitHub `owner/repo` for skills.sh-sourced entries.","pattern":"^[A-Za-z0-9._/-]+$","type":"string","x-struct":null,"x-validate":null}},"type":"object","x-struct":null,"x-validate":null},"type":"array","x-struct":null,"x-validate":null},"system":{"description":"System prompt.","type":"string","x-struct":null,"x-validate":null},"updated_at":{"format":"date-time","type":"string","x-struct":null,"x-validate":null}},"required":["id","name","model","runtime"],"title":"Agent","type":"object","x-struct":"Elixir.FountainWeb.Schemas.Agent","x-validate":null},"Conversation":{"description":"One chat with one agent inside one sandbox.","properties":{"agent_id":{"format":"uuid","nullable":true,"type":"string","x-struct":null,"x-validate":null},"id":{"format":"uuid","type":"string","x-struct":null,"x-validate":null},"inserted_at":{"format":"date-time","type":"string","x-struct":null,"x-validate":null},"runtime":{"enum":["claude","codex","gemini","opencode"],"type":"string","x-struct":null,"x-validate":null},"runtime_session_id":{"nullable":true,"type":"string","x-struct":null,"x-validate":null},"sandbox":{"nullable":true,"oneOf":[{"$ref":"#/components/schemas/Sandbox"}],"x-struct":null,"x-validate":null},"sandbox_id":{"format":"uuid","nullable":true,"type":"string","x-struct":null,"x-validate":null},"status":{"enum":["pending","running","idle","completed","failed","terminated"],"type":"string","x-struct":null,"x-validate":null},"updated_at":{"format":"date-time","type":"string","x-struct":null,"x-validate":null},"vault_id":{"format":"uuid","nullable":true,"type":"string","x-struct":null,"x-validate":null}},"required":["id","runtime","status"],"title":"Conversation","type":"object","x-struct":"Elixir.FountainWeb.Schemas.Conversation","x-validate":null},"EnvironmentListResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/Environment"},"type":"array","x-struct":null,"x-validate":null}},"required":["data"],"title":"EnvironmentListResponse","type":"object","x-struct":"Elixir.FountainWeb.Schemas.EnvironmentListResponse","x-validate":null},"VaultSecretRequest":{"properties":{"key":{"type":"string","x-struct":null,"x-validate":null},"value":{"description":"Secret value (write-only).","type":"string","x-struct":null,"x-validate":null}},"required":["key","value"],"title":"VaultSecretRequest","type":"object","x-struct":"Elixir.FountainWeb.Schemas.VaultSecretRequest","x-validate":null},"PromptRequest":{"properties":{"images":{"description":"Optional images to attach to this prompt.","items":{"$ref":"#/components/schemas/ImageInput"},"nullable":true,"type":"array","x-struct":null,"x-validate":null},"prompt":{"type":"string","x-struct":null,"x-validate":null}},"required":["prompt"],"title":"PromptRequest","type":"object","x-struct":"Elixir.FountainWeb.Schemas.PromptRequest","x-validate":null},"Turn":{"description":"One prompt → exit_code cycle within a conversation.","properties":{"ended_at":{"format":"date-time","nullable":true,"type":"string","x-struct":null,"x-validate":null},"exit_code":{"nullable":true,"type":"integer","x-struct":null,"x-validate":null},"id":{"format":"uuid","type":"string","x-struct":null,"x-validate":null},"image_count":{"description":"Number of images attached to this turn.","type":"integer","x-struct":null,"x-validate":null},"inserted_at":{"format":"date-time","type":"string","x-struct":null,"x-validate":null},"prompt":{"type":"string","x-struct":null,"x-validate":null},"started_at":{"format":"date-time","nullable":true,"type":"string","x-struct":null,"x-validate":null},"status":{"enum":["pending","running","completed","failed","interrupted"],"type":"string","x-struct":null,"x-validate":null},"turn_number":{"type":"integer","x-struct":null,"x-validate":null}},"required":["id","turn_number","prompt","status"],"title":"Turn","type":"object","x-struct":"Elixir.FountainWeb.Schemas.Turn","x-validate":null},"VaultSecretListResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/VaultSecret"},"type":"array","x-struct":null,"x-validate":null}},"required":["data"],"title":"VaultSecretListResponse","type":"object","x-struct":"Elixir.FountainWeb.Schemas.VaultSecretListResponse","x-validate":null},"HealthResponse":{"properties":{"status":{"example":"ok","type":"string","x-struct":null,"x-validate":null}},"required":["status"],"title":"HealthResponse","type":"object","x-struct":"Elixir.FountainWeb.Schemas.HealthResponse","x-validate":null},"VaultResponse":{"properties":{"data":{"$ref":"#/components/schemas/Vault"}},"required":["data"],"title":"VaultResponse","type":"object","x-struct":"Elixir.FountainWeb.Schemas.VaultResponse","x-validate":null},"PromptResponse":{"properties":{"status":{"example":"queued","type":"string","x-struct":null,"x-validate":null}},"required":["status"],"title":"PromptResponse","type":"object","x-struct":"Elixir.FountainWeb.Schemas.PromptResponse","x-validate":null},"AgentListResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/Agent"},"type":"array","x-struct":null,"x-validate":null}},"required":["data"],"title":"AgentListResponse","type":"object","x-struct":"Elixir.FountainWeb.Schemas.AgentListResponse","x-validate":null},"AgentUpdate":{"properties":{"allowed_vault_ids":{"description":"Vaults a conversation may attach to this agent. null (default) allows any vault the tenant owns; an empty list forbids attaching any vault; a non-empty list is an allowlist.","items":{"format":"uuid","type":"string","x-struct":null,"x-validate":null},"nullable":true,"type":"array","x-struct":null,"x-validate":null},"description":{"type":"string","x-struct":null,"x-validate":null},"environment_id":{"format":"uuid","nullable":true,"type":"string","x-struct":null,"x-validate":null},"mcp_servers":{"additionalProperties":true,"type":"object","x-struct":null,"x-validate":null},"metadata":{"additionalProperties":true,"type":"object","x-struct":null,"x-validate":null},"model":{"pattern":"^[a-z0-9_-]+/[a-z0-9._-]+$","type":"string","x-struct":null,"x-validate":null},"name":{"maxLength":200,"minLength":1,"type":"string","x-struct":null,"x-validate":null},"runtime":{"enum":["claude","codex","gemini","opencode"],"type":"string","x-struct":null,"x-validate":null},"skills":{"description":"Each entry is either inline (`{name, content}` — full SKILL.md text written to the sprite) or github (`{source, ref?, name?}` — installed on the sprite via the skills.sh CLI, optionally pinned to a tag/branch/sha via `ref`). Exactly one of `content` or `source` must be set on each entry.","items":{"properties":{"content":{"description":"Full SKILL.md body for inline entries.","type":"string","x-struct":null,"x-validate":null},"name":{"description":"Skill name (required for inline entries).","type":"string","x-struct":null,"x-validate":null},"ref":{"description":"Optional tag, branch, or sha pinning a github-sourced skill (installed as `owner/repo@ref`). Without it the default branch is fetched at spawn time.","pattern":"^[A-Za-z0-9._/-]+$","type":"string","x-struct":null,"x-validate":null},"source":{"description":"GitHub `owner/repo` for skills.sh-sourced entries.","pattern":"^[A-Za-z0-9._/-]+$","type":"string","x-struct":null,"x-validate":null}},"type":"object","x-struct":null,"x-validate":null},"type":"array","x-struct":null,"x-validate":null},"system":{"type":"string","x-struct":null,"x-validate":null}},"title":"AgentUpdate","type":"object","x-struct":"Elixir.FountainWeb.Schemas.AgentUpdate","x-validate":null},"ConversationListResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/Conversation"},"type":"array","x-struct":null,"x-validate":null}},"required":["data"],"title":"ConversationListResponse","type":"object","x-struct":"Elixir.FountainWeb.Schemas.ConversationListResponse","x-validate":null},"VaultRequest":{"properties":{"description":{"type":"string","x-struct":null,"x-validate":null},"metadata":{"additionalProperties":true,"type":"object","x-struct":null,"x-validate":null},"name":{"maxLength":200,"minLength":1,"type":"string","x-struct":null,"x-validate":null}},"required":["name"],"title":"VaultRequest","type":"object","x-struct":"Elixir.FountainWeb.Schemas.VaultRequest","x-validate":null},"ConversationResponse":{"properties":{"data":{"$ref":"#/components/schemas/Conversation"}},"required":["data"],"title":"ConversationResponse","type":"object","x-struct":"Elixir.FountainWeb.Schemas.ConversationResponse","x-validate":null},"Vault":{"description":"A free-floating bag of env-var overrides selected at conversation creation. Vault values override an environment's baseline secrets when the same key is set on both.","properties":{"description":{"type":"string","x-struct":null,"x-validate":null},"id":{"format":"uuid","type":"string","x-struct":null,"x-validate":null},"inserted_at":{"format":"date-time","type":"string","x-struct":null,"x-validate":null},"metadata":{"additionalProperties":true,"type":"object","x-struct":null,"x-validate":null},"name":{"type":"string","x-struct":null,"x-validate":null},"updated_at":{"format":"date-time","type":"string","x-struct":null,"x-validate":null}},"required":["id","name"],"title":"Vault","type":"object","x-struct":"Elixir.FountainWeb.Schemas.Vault","x-validate":null},"SecretResponse":{"properties":{"data":{"$ref":"#/components/schemas/Secret"}},"required":["data"],"title":"SecretResponse","type":"object","x-struct":"Elixir.FountainWeb.Schemas.SecretResponse","x-validate":null},"SecretRequest":{"properties":{"key":{"type":"string","x-struct":null,"x-validate":null},"value":{"description":"Secret value (write-only).","type":"string","x-struct":null,"x-validate":null}},"required":["key","value"],"title":"SecretRequest","type":"object","x-struct":"Elixir.FountainWeb.Schemas.SecretRequest","x-validate":null},"AgentRequest":{"properties":{"allowed_vault_ids":{"description":"Vaults a conversation may attach to this agent. null (default) allows any vault the tenant owns; an empty list forbids attaching any vault; a non-empty list is an allowlist.","items":{"format":"uuid","type":"string","x-struct":null,"x-validate":null},"nullable":true,"type":"array","x-struct":null,"x-validate":null},"description":{"type":"string","x-struct":null,"x-validate":null},"environment_id":{"format":"uuid","nullable":true,"type":"string","x-struct":null,"x-validate":null},"mcp_servers":{"additionalProperties":true,"type":"object","x-struct":null,"x-validate":null},"metadata":{"additionalProperties":true,"type":"object","x-struct":null,"x-validate":null},"model":{"pattern":"^[a-z0-9_-]+/[a-z0-9._-]+$","type":"string","x-struct":null,"x-validate":null},"name":{"maxLength":200,"minLength":1,"type":"string","x-struct":null,"x-validate":null},"runtime":{"enum":["claude","codex","gemini","opencode"],"type":"string","x-struct":null,"x-validate":null},"skills":{"description":"Each entry is either inline (`{name, content}` — full SKILL.md text written to the sprite) or github (`{source, ref?, name?}` — installed on the sprite via the skills.sh CLI, optionally pinned to a tag/branch/sha via `ref`). Exactly one of `content` or `source` must be set on each entry.","items":{"properties":{"content":{"description":"Full SKILL.md body for inline entries.","type":"string","x-struct":null,"x-validate":null},"name":{"description":"Skill name (required for inline entries).","type":"string","x-struct":null,"x-validate":null},"ref":{"description":"Optional tag, branch, or sha pinning a github-sourced skill (installed as `owner/repo@ref`). Without it the default branch is fetched at spawn time.","pattern":"^[A-Za-z0-9._/-]+$","type":"string","x-struct":null,"x-validate":null},"source":{"description":"GitHub `owner/repo` for skills.sh-sourced entries.","pattern":"^[A-Za-z0-9._/-]+$","type":"string","x-struct":null,"x-validate":null}},"type":"object","x-struct":null,"x-validate":null},"type":"array","x-struct":null,"x-validate":null},"system":{"type":"string","x-struct":null,"x-validate":null}},"required":["name","model","runtime"],"title":"AgentRequest","type":"object","x-struct":"Elixir.FountainWeb.Schemas.AgentRequest","x-validate":null},"EnvironmentRequest":{"properties":{"env_vars":{"additionalProperties":{"type":"string","x-struct":null,"x-validate":null},"type":"object","x-struct":null,"x-validate":null},"metadata":{"additionalProperties":true,"type":"object","x-struct":null,"x-validate":null},"name":{"maxLength":200,"minLength":1,"type":"string","x-struct":null,"x-validate":null},"networking_config":{"additionalProperties":true,"description":"Refines networking_type: limited. allowed_hosts is the only key honored today; unknown keys are ignored. Under limited, egress is restricted to the allowlisted domains. With no allowed_hosts (or an empty list), the sandbox denies all egress by default — this is a deny-all, not an allow-all.","properties":{"allowed_hosts":{"description":"Domains the sandbox may reach when networking_type is limited.","items":{"type":"string","x-struct":null,"x-validate":null},"type":"array","x-struct":null,"x-validate":null}},"type":"object","x-struct":null,"x-validate":null},"networking_type":{"enum":["unrestricted","limited"],"type":"string","x-struct":null,"x-validate":null},"packages":{"additionalProperties":true,"type":"object","x-struct":null,"x-validate":null},"repositories":{"items":{"$ref":"#/components/schemas/Repository"},"type":"array","x-struct":null,"x-validate":null},"setup_script":{"type":"string","x-struct":null,"x-validate":null}},"required":["name"],"title":"EnvironmentRequest","type":"object","x-struct":"Elixir.FountainWeb.Schemas.EnvironmentRequest","x-validate":null},"Environment":{"description":"A reusable sandbox environment: packages, env vars, repos, networking.","properties":{"env_vars":{"additionalProperties":{"type":"string","x-struct":null,"x-validate":null},"type":"object","x-struct":null,"x-validate":null},"id":{"format":"uuid","type":"string","x-struct":null,"x-validate":null},"inserted_at":{"format":"date-time","type":"string","x-struct":null,"x-validate":null},"metadata":{"additionalProperties":true,"type":"object","x-struct":null,"x-validate":null},"name":{"type":"string","x-struct":null,"x-validate":null},"networking_config":{"additionalProperties":true,"description":"Refines networking_type: limited. allowed_hosts is the only key honored today; unknown keys are ignored. Under limited, egress is restricted to the allowlisted domains. With no allowed_hosts (or an empty list), the sandbox denies all egress by default — this is a deny-all, not an allow-all.","properties":{"allowed_hosts":{"description":"Domains the sandbox may reach when networking_type is limited.","items":{"type":"string","x-struct":null,"x-validate":null},"type":"array","x-struct":null,"x-validate":null}},"type":"object","x-struct":null,"x-validate":null},"networking_type":{"enum":["unrestricted","limited"],"type":"string","x-struct":null,"x-validate":null},"packages":{"additionalProperties":true,"type":"object","x-struct":null,"x-validate":null},"repositories":{"items":{"$ref":"#/components/schemas/Repository"},"type":"array","x-struct":null,"x-validate":null},"setup_script":{"type":"string","x-struct":null,"x-validate":null},"updated_at":{"format":"date-time","type":"string","x-struct":null,"x-validate":null}},"required":["id","name"],"title":"Environment","type":"object","x-struct":"Elixir.FountainWeb.Schemas.Environment","x-validate":null},"VaultListResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/Vault"},"type":"array","x-struct":null,"x-validate":null}},"required":["data"],"title":"VaultListResponse","type":"object","x-struct":"Elixir.FountainWeb.Schemas.VaultListResponse","x-validate":null},"AgentResponse":{"properties":{"data":{"$ref":"#/components/schemas/Agent"}},"required":["data"],"title":"AgentResponse","type":"object","x-struct":"Elixir.FountainWeb.Schemas.AgentResponse","x-validate":null}},"securitySchemes":{"bearer":{"description":"ADMIN_TOKEN configured at boot.","scheme":"bearer","type":"http"}}},"info":{"description":"HTTP API for Agent on Demand. The same surface backs the LiveView UI\nand the `aod` CLI; if it's not here, it doesn't exist yet.\n\nAll `/api/*` endpoints require a bearer token (`ADMIN_TOKEN`).\n","title":"Agent on Demand","version":"0.1.0"},"openapi":"3.0.0","paths":{"/api/agents":{"get":{"callbacks":{},"operationId":"FountainWeb.AgentController.index","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentListResponse"}}},"description":"Agents"}},"summary":"List agents","tags":["Agents"]},"post":{"callbacks":{},"operationId":"FountainWeb.AgentController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentRequest"}}},"description":"Agent attributes","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}},"description":"Agent"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Create an agent","tags":["Agents"]}},"/api/agents/{id}":{"delete":{"callbacks":{},"operationId":"FountainWeb.AgentController.delete","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"responses":{"204":{"description":"Deleted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Delete an agent","tags":["Agents"]},"get":{"callbacks":{},"operationId":"FountainWeb.AgentController.show","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}},"description":"Agent"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Get an agent","tags":["Agents"]},"patch":{"callbacks":{},"description":"Every field is optional; the server merges into the existing record.","operationId":"FountainWeb.AgentController.update (2)","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentUpdate"}}},"description":"Partial agent attributes","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}},"description":"Agent"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Update an agent (partial)","tags":["Agents"]},"put":{"callbacks":{},"description":"Every field is optional; the server merges into the existing record.","operationId":"FountainWeb.AgentController.update","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentUpdate"}}},"description":"Partial agent attributes","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}},"description":"Agent"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Update an agent (partial)","tags":["Agents"]}},"/api/conversations":{"get":{"callbacks":{},"operationId":"FountainWeb.ConversationController.index","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationListResponse"}}},"description":"Conversations"}},"summary":"List conversations","tags":["Conversations"]},"post":{"callbacks":{},"description":"Creates a sandbox + conversation pair, starts the runtime in a fresh sprite, and (if `prompt` is supplied) sends it as turn 1. Pass `X-Fountain-Parent-Conversation-Id` header to record which conversation spawned this one. Legacy `X-AoD-Parent-Conversation-Id` is still accepted for sprites provisioned before the rename.","operationId":"FountainWeb.ConversationController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationCreateRequest"}}},"description":"Conversation attrs","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationResponse"}}},"description":"Conversation"},"402":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string","x-struct":null,"x-validate":null},"upgrade_url":{"type":"string","x-struct":null,"x-validate":null}},"type":"object","x-struct":null,"x-validate":null}}},"description":"Subscription required"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Agent not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Start a conversation","tags":["Conversations"]}},"/api/conversations/{conversation_id}/interrupt":{"post":{"callbacks":{},"operationId":"FountainWeb.ConversationController.interrupt","parameters":[{"description":"","in":"path","name":"conversation_id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"responses":{"204":{"description":"Interrupted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"No turn running"}},"summary":"Interrupt the running turn","tags":["Conversations"]}},"/api/conversations/{conversation_id}/prompts":{"post":{"callbacks":{},"description":"Queues a new turn. If the ConversationServer has been GC'd (e.g. across a BEAM restart) a fresh sprite is provisioned and the runtime resumes via its session id.","operationId":"FountainWeb.ConversationController.prompt","parameters":[{"description":"","in":"path","name":"conversation_id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromptRequest"}}},"description":"Prompt","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromptResponse"}}},"description":"Queued"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Busy"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Send another prompt","tags":["Conversations"]}},"/api/conversations/{conversation_id}/stream":{"get":{"callbacks":{},"description":"Server-Sent Events stream of the conversation's log events. The `Last-Event-ID` request header resumes from a known event id; missed events are replayed before the live tail begins. Keep-alive heartbeats every 15s as `: heartbeat` comments.","operationId":"FountainWeb.ConversationController.stream","parameters":[{"description":"","in":"path","name":"conversation_id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}},{"description":"Resume after this event id (integer as string). Missing or unparseable values are treated as 0.","in":"header","name":"Last-Event-ID","required":false,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"responses":{"200":{"content":{"text/event-stream":{"schema":{"type":"string","x-struct":null,"x-validate":null}}},"description":"SSE stream"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Stream log events (SSE)","tags":["Conversations"]}},"/api/conversations/{conversation_id}/terminate":{"post":{"callbacks":{},"description":"Tears down the sprite and marks the conversation `terminated`. Idempotent for already-dead conversations.","operationId":"FountainWeb.ConversationController.terminate","parameters":[{"description":"","in":"path","name":"conversation_id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"responses":{"204":{"description":"Terminated"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Terminate a conversation","tags":["Conversations"]}},"/api/conversations/{conversation_id}/turns":{"get":{"callbacks":{},"operationId":"FountainWeb.ConversationController.turns","parameters":[{"description":"","in":"path","name":"conversation_id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TurnListResponse"}}},"description":"Turns"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"List turns in a conversation","tags":["Conversations"]}},"/api/conversations/{id}":{"delete":{"callbacks":{},"description":"Tears down the sprite if alive, then deletes the conversation row (cascades to turns and log events).","operationId":"FountainWeb.ConversationController.delete","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"responses":{"204":{"description":"Deleted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Delete a conversation","tags":["Conversations"]},"get":{"callbacks":{},"operationId":"FountainWeb.ConversationController.show","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationResponse"}}},"description":"Conversation"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Get a conversation","tags":["Conversations"]}},"/api/environments":{"get":{"callbacks":{},"operationId":"FountainWeb.EnvironmentController.index","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentListResponse"}}},"description":"Environments"}},"summary":"List environments","tags":["Environments"]},"post":{"callbacks":{},"operationId":"FountainWeb.EnvironmentController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRequest"}}},"description":"Environment attributes","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentResponse"}}},"description":"Environment"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Create an environment","tags":["Environments"]}},"/api/environments/{environment_id}/secrets":{"get":{"callbacks":{},"operationId":"FountainWeb.SecretController.index","parameters":[{"description":"","in":"path","name":"environment_id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SecretListResponse"}}},"description":"Secrets"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"List secrets in an environment","tags":["Secrets"]},"post":{"callbacks":{},"description":"Sets the value for `key`. If the key exists, the value is overwritten. Values are write-only — subsequent reads never return them.","operationId":"FountainWeb.SecretController.create","parameters":[{"description":"","in":"path","name":"environment_id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SecretRequest"}}},"description":"Secret","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SecretResponse"}}},"description":"Secret"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Upsert a secret","tags":["Secrets"]}},"/api/environments/{environment_id}/secrets/{id}":{"delete":{"callbacks":{},"operationId":"FountainWeb.SecretController.delete","parameters":[{"description":"","in":"path","name":"environment_id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}},{"description":"Secret key.","in":"path","name":"id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"responses":{"204":{"description":"Deleted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Delete a secret by key","tags":["Secrets"]}},"/api/environments/{id}":{"delete":{"callbacks":{},"operationId":"FountainWeb.EnvironmentController.delete","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"responses":{"204":{"description":"Deleted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Delete an environment","tags":["Environments"]},"get":{"callbacks":{},"operationId":"FountainWeb.EnvironmentController.show","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentResponse"}}},"description":"Environment"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Get an environment","tags":["Environments"]},"patch":{"callbacks":{},"description":"Every field is optional; the server merges into the existing record.","operationId":"FountainWeb.EnvironmentController.update (2)","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentUpdate"}}},"description":"Partial environment attributes","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentResponse"}}},"description":"Environment"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Update an environment (partial)","tags":["Environments"]},"put":{"callbacks":{},"description":"Every field is optional; the server merges into the existing record.","operationId":"FountainWeb.EnvironmentController.update","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentUpdate"}}},"description":"Partial environment attributes","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentResponse"}}},"description":"Environment"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Update an environment (partial)","tags":["Environments"]}},"/api/vaults":{"get":{"callbacks":{},"operationId":"FountainWeb.VaultController.index","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultListResponse"}}},"description":"Vaults"}},"summary":"List vaults","tags":["Vaults"]},"post":{"callbacks":{},"operationId":"FountainWeb.VaultController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultRequest"}}},"description":"Vault attributes","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultResponse"}}},"description":"Vault"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Create a vault","tags":["Vaults"]}},"/api/vaults/{id}":{"delete":{"callbacks":{},"operationId":"FountainWeb.VaultController.delete","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"responses":{"204":{"description":"Deleted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Delete a vault","tags":["Vaults"]},"get":{"callbacks":{},"operationId":"FountainWeb.VaultController.show","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultResponse"}}},"description":"Vault"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Get a vault","tags":["Vaults"]},"patch":{"callbacks":{},"description":"Every field is optional; the server merges into the existing record.","operationId":"FountainWeb.VaultController.update (2)","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultUpdate"}}},"description":"Partial vault attributes","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultResponse"}}},"description":"Vault"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Update a vault (partial)","tags":["Vaults"]},"put":{"callbacks":{},"description":"Every field is optional; the server merges into the existing record.","operationId":"FountainWeb.VaultController.update","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultUpdate"}}},"description":"Partial vault attributes","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultResponse"}}},"description":"Vault"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Update a vault (partial)","tags":["Vaults"]}},"/api/vaults/{vault_id}/secrets":{"get":{"callbacks":{},"operationId":"FountainWeb.VaultSecretController.index","parameters":[{"description":"","in":"path","name":"vault_id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultSecretListResponse"}}},"description":"Vault Secrets"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"List secrets in a vault","tags":["Vault Secrets"]},"post":{"callbacks":{},"description":"Sets the value for `key`. If the key exists, the value is overwritten. Values are write-only — subsequent reads never return them.","operationId":"FountainWeb.VaultSecretController.create","parameters":[{"description":"","in":"path","name":"vault_id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultSecretRequest"}}},"description":"Vault Secret","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultSecretResponse"}}},"description":"Vault Secret"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Upsert a vault secret","tags":["Vault Secrets"]}},"/api/vaults/{vault_id}/secrets/{id}":{"delete":{"callbacks":{},"operationId":"FountainWeb.VaultSecretController.delete","parameters":[{"description":"","in":"path","name":"vault_id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}},{"description":"Secret key.","in":"path","name":"id","required":true,"schema":{"type":"string","x-struct":null,"x-validate":null}}],"responses":{"204":{"description":"Deleted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Delete a vault secret by key","tags":["Vault Secrets"]}},"/health":{"get":{"callbacks":{},"description":"Public, unauthenticated. Returns `{\"status\": \"ok\"}` if the app is up.","operationId":"FountainWeb.HealthController.show","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}},"description":"Health response"}},"security":[],"summary":"Liveness probe","tags":["Health"]}}},"security":[{"bearer":[]}],"servers":[{"url":"http://localhost:4000","variables":{}}],"tags":[]}
1
+ {"components":{"responses":{},"schemas":{"SecretListResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/Secret"},"type":"array"}},"required":["data"],"title":"SecretListResponse","type":"object"},"ConversationCreateRequest":{"properties":{"agent_id":{"format":"uuid","type":"string"},"images":{"description":"Optional images to attach to the initial prompt.","items":{"$ref":"#/components/schemas/ImageInput"},"nullable":true,"type":"array"},"prompt":{"description":"Optional first turn prompt.","type":"string"},"sprite_name":{"description":"Override the auto-generated sprite name.","type":"string"},"vault_id":{"description":"Optional vault whose secrets override the environment's baseline at sprite spawn. Must satisfy the agent's allowed_vault_ids when that allowlist is set.","format":"uuid","nullable":true,"type":"string"}},"required":["agent_id"],"title":"ConversationCreateRequest","type":"object"},"Secret":{"description":"A named secret. Values are write-only — the API never returns them.","properties":{"environment_id":{"format":"uuid","type":"string"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"key":{"type":"string"},"updated_at":{"format":"date-time","type":"string"}},"required":["id","key","environment_id"],"title":"Secret","type":"object"},"VaultUpdate":{"properties":{"description":{"type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"name":{"maxLength":200,"minLength":1,"type":"string"}},"title":"VaultUpdate","type":"object"},"ImageInput":{"description":"A base64-encoded image to attach to a prompt.","properties":{"data":{"description":"Base64-encoded image bytes.","type":"string"},"media_type":{"description":"MIME type of the image.","enum":["image/png","image/jpeg","image/gif","image/webp"],"type":"string"}},"required":["data","media_type"],"title":"ImageInput","type":"object"},"Repository":{"properties":{"mount_path":{"pattern":"^/","type":"string"},"url":{"format":"uri","pattern":"^https://","type":"string"}},"required":["url","mount_path"],"title":"Repository","type":"object"},"EnvironmentUpdate":{"properties":{"env_vars":{"additionalProperties":{"type":"string"},"type":"object"},"metadata":{"additionalProperties":true,"type":"object"},"name":{"maxLength":200,"minLength":1,"type":"string"},"networking_config":{"additionalProperties":true,"description":"Refines networking_type: limited. allowed_hosts is the only key honored today; unknown keys are ignored. Under limited, egress is restricted to the allowlisted domains. With no allowed_hosts (or an empty list), the sandbox denies all egress by default — this is a deny-all, not an allow-all.","properties":{"allowed_hosts":{"description":"Domains the sandbox may reach when networking_type is limited.","items":{"type":"string"},"type":"array"}},"type":"object"},"networking_type":{"enum":["unrestricted","limited"],"type":"string"},"packages":{"additionalProperties":true,"type":"object"},"repositories":{"items":{"$ref":"#/components/schemas/Repository"},"type":"array"},"setup_script":{"type":"string"}},"title":"EnvironmentUpdate","type":"object"},"VaultSecret":{"description":"A named secret in a vault. Values are write-only — the API never returns them.","properties":{"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"key":{"type":"string"},"updated_at":{"format":"date-time","type":"string"},"vault_id":{"format":"uuid","type":"string"}},"required":["id","key","vault_id"],"title":"VaultSecret","type":"object"},"TurnListResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/Turn"},"type":"array"}},"required":["data"],"title":"TurnListResponse","type":"object"},"Sandbox":{"description":"One sprite lifespan owned by a conversation.","properties":{"id":{"format":"uuid","type":"string"},"sprite_name":{"type":"string"},"status":{"enum":["pending","starting","ready","terminated","failed"],"type":"string"}},"required":["id","sprite_name","status"],"title":"Sandbox","type":"object"},"Error":{"properties":{"error":{"type":"string"}},"required":["error"],"title":"Error","type":"object"},"ReadinessResponse":{"properties":{"checks":{"additionalProperties":{"enum":["ok","error"],"type":"string"},"description":"Per-dependency result. `ok` or `error`, with no further detail.","example":{"database":"ok"},"type":"object"},"status":{"enum":["ok","error"],"example":"ok","type":"string"}},"required":["status","checks"],"title":"ReadinessResponse","type":"object"},"VaultSecretResponse":{"properties":{"data":{"$ref":"#/components/schemas/VaultSecret"}},"required":["data"],"title":"VaultSecretResponse","type":"object"},"EnvironmentResponse":{"properties":{"data":{"$ref":"#/components/schemas/Environment"}},"required":["data"],"title":"EnvironmentResponse","type":"object"},"ChangesetError":{"description":"Validation errors keyed by field, with each value an array of messages.","properties":{"errors":{"additionalProperties":{"items":{"type":"string"},"type":"array"},"type":"object"}},"required":["errors"],"title":"ChangesetError","type":"object"},"Agent":{"description":"An AI agent definition: runtime, model, skills, MCP, env.","properties":{"allowed_vault_ids":{"description":"Vaults a conversation may attach to this agent. null (default) allows any vault the tenant owns; an empty list forbids attaching any vault; a non-empty list is an allowlist. Vault values override the agent's environment on key collision, so this scopes who can override reviewed config.","items":{"format":"uuid","type":"string"},"nullable":true,"type":"array"},"description":{"type":"string"},"environment_id":{"format":"uuid","nullable":true,"type":"string"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"mcp_servers":{"additionalProperties":true,"type":"object"},"metadata":{"additionalProperties":true,"type":"object"},"model":{"description":"Canonical provider/model_id (e.g. anthropic/claude-sonnet-4-6).","pattern":"^[a-z0-9_-]+/[a-z0-9._-]+$","type":"string"},"name":{"type":"string"},"runtime":{"enum":["claude","codex","gemini","opencode"],"type":"string"},"skills":{"description":"Each entry is either inline (`{name, content}` — full SKILL.md text written to the sprite) or github (`{source, ref?, name?}` — installed on the sprite via the skills.sh CLI, optionally pinned to a tag/branch/sha via `ref`). Exactly one of `content` or `source` must be set on each entry.","items":{"properties":{"content":{"description":"Full SKILL.md body for inline entries.","type":"string"},"name":{"description":"Skill name (required for inline entries).","type":"string"},"ref":{"description":"Optional tag, branch, or sha pinning a github-sourced skill (installed as `owner/repo@ref`). Without it the default branch is fetched at spawn time.","pattern":"^[A-Za-z0-9._/-]+$","type":"string"},"source":{"description":"GitHub `owner/repo` for skills.sh-sourced entries.","pattern":"^[A-Za-z0-9._/-]+$","type":"string"}},"type":"object"},"type":"array"},"system":{"description":"System prompt.","type":"string"},"updated_at":{"format":"date-time","type":"string"}},"required":["id","name","model","runtime"],"title":"Agent","type":"object"},"ApplyResult":{"properties":{"action":{"enum":["created","updated","error"],"type":"string"},"errors":{"additionalProperties":true,"nullable":true,"type":"object"},"kind":{"type":"string"},"name":{"type":"string"},"secrets":{"items":{"$ref":"#/components/schemas/ApplySecretResult"},"type":"array"}},"required":["kind","name","action"],"title":"ApplyResult","type":"object"},"Conversation":{"description":"One chat with one agent inside one sandbox.","properties":{"agent_id":{"format":"uuid","nullable":true,"type":"string"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"runtime":{"enum":["claude","codex","gemini","opencode"],"type":"string"},"runtime_session_id":{"nullable":true,"type":"string"},"sandbox":{"nullable":true,"oneOf":[{"$ref":"#/components/schemas/Sandbox"}]},"sandbox_id":{"format":"uuid","nullable":true,"type":"string"},"status":{"enum":["pending","running","idle","completed","failed","terminated"],"type":"string"},"updated_at":{"format":"date-time","type":"string"},"vault_id":{"format":"uuid","nullable":true,"type":"string"}},"required":["id","runtime","status"],"title":"Conversation","type":"object"},"EnvironmentListResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/Environment"},"type":"array"}},"required":["data"],"title":"EnvironmentListResponse","type":"object"},"VaultSecretRequest":{"properties":{"key":{"type":"string"},"value":{"description":"Secret value (write-only).","type":"string"}},"required":["key","value"],"title":"VaultSecretRequest","type":"object"},"PromptRequest":{"properties":{"images":{"description":"Optional images to attach to this prompt.","items":{"$ref":"#/components/schemas/ImageInput"},"nullable":true,"type":"array"},"prompt":{"type":"string"}},"required":["prompt"],"title":"PromptRequest","type":"object"},"Turn":{"description":"One prompt → exit_code cycle within a conversation.","properties":{"ended_at":{"format":"date-time","nullable":true,"type":"string"},"exit_code":{"nullable":true,"type":"integer"},"id":{"format":"uuid","type":"string"},"image_count":{"description":"Number of images attached to this turn.","type":"integer"},"inserted_at":{"format":"date-time","type":"string"},"prompt":{"type":"string"},"started_at":{"format":"date-time","nullable":true,"type":"string"},"status":{"enum":["pending","running","completed","failed","interrupted"],"type":"string"},"turn_number":{"type":"integer"}},"required":["id","turn_number","prompt","status"],"title":"Turn","type":"object"},"VaultSecretListResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/VaultSecret"},"type":"array"}},"required":["data"],"title":"VaultSecretListResponse","type":"object"},"HealthResponse":{"properties":{"status":{"example":"ok","type":"string"}},"required":["status"],"title":"HealthResponse","type":"object"},"VaultResponse":{"properties":{"data":{"$ref":"#/components/schemas/Vault"}},"required":["data"],"title":"VaultResponse","type":"object"},"ManifestResource":{"description":"One compiled document from a fountain.yml manifest. `spec` matches the create/update schema for the kind, plus an inline `secrets` map (Environment and Vault). Agent specs may reference an environment by name via `environment`; the server resolves it to `environment_id`.","properties":{"kind":{"enum":["Environment","Vault","Agent"],"type":"string"},"name":{"maxLength":200,"minLength":1,"type":"string"},"spec":{"additionalProperties":true,"type":"object"}},"required":["kind","name"],"title":"ManifestResource","type":"object"},"ApplySecretResult":{"description":"Outcome for one secret key. Values are never echoed back.","properties":{"action":{"enum":["upserted","error"],"type":"string"},"errors":{"additionalProperties":true,"nullable":true,"type":"object"},"key":{"type":"string"}},"required":["key","action"],"title":"ApplySecretResult","type":"object"},"PromptResponse":{"properties":{"status":{"example":"queued","type":"string"}},"required":["status"],"title":"PromptResponse","type":"object"},"AgentListResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/Agent"},"type":"array"}},"required":["data"],"title":"AgentListResponse","type":"object"},"AgentUpdate":{"properties":{"allowed_vault_ids":{"description":"Vaults a conversation may attach to this agent. null (default) allows any vault the tenant owns; an empty list forbids attaching any vault; a non-empty list is an allowlist.","items":{"format":"uuid","type":"string"},"nullable":true,"type":"array"},"description":{"type":"string"},"environment_id":{"format":"uuid","nullable":true,"type":"string"},"mcp_servers":{"additionalProperties":true,"type":"object"},"metadata":{"additionalProperties":true,"type":"object"},"model":{"pattern":"^[a-z0-9_-]+/[a-z0-9._-]+$","type":"string"},"name":{"maxLength":200,"minLength":1,"type":"string"},"runtime":{"enum":["claude","codex","gemini","opencode"],"type":"string"},"skills":{"description":"Each entry is either inline (`{name, content}` — full SKILL.md text written to the sprite) or github (`{source, ref?, name?}` — installed on the sprite via the skills.sh CLI, optionally pinned to a tag/branch/sha via `ref`). Exactly one of `content` or `source` must be set on each entry.","items":{"properties":{"content":{"description":"Full SKILL.md body for inline entries.","type":"string"},"name":{"description":"Skill name (required for inline entries).","type":"string"},"ref":{"description":"Optional tag, branch, or sha pinning a github-sourced skill (installed as `owner/repo@ref`). Without it the default branch is fetched at spawn time.","pattern":"^[A-Za-z0-9._/-]+$","type":"string"},"source":{"description":"GitHub `owner/repo` for skills.sh-sourced entries.","pattern":"^[A-Za-z0-9._/-]+$","type":"string"}},"type":"object"},"type":"array"},"system":{"type":"string"}},"title":"AgentUpdate","type":"object"},"ConversationListResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/Conversation"},"type":"array"}},"required":["data"],"title":"ConversationListResponse","type":"object"},"VaultRequest":{"properties":{"description":{"type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"name":{"maxLength":200,"minLength":1,"type":"string"}},"required":["name"],"title":"VaultRequest","type":"object"},"ConversationResponse":{"properties":{"data":{"$ref":"#/components/schemas/Conversation"}},"required":["data"],"title":"ConversationResponse","type":"object"},"Vault":{"description":"A free-floating bag of env-var overrides selected at conversation creation. Vault values override an environment's baseline secrets when the same key is set on both.","properties":{"description":{"type":"string"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"name":{"type":"string"},"updated_at":{"format":"date-time","type":"string"}},"required":["id","name"],"title":"Vault","type":"object"},"ApplyResponse":{"properties":{"data":{"properties":{"results":{"items":{"$ref":"#/components/schemas/ApplyResult"},"type":"array"}},"required":["results"],"type":"object"}},"required":["data"],"title":"ApplyResponse","type":"object"},"SecretResponse":{"properties":{"data":{"$ref":"#/components/schemas/Secret"}},"required":["data"],"title":"SecretResponse","type":"object"},"SecretRequest":{"properties":{"key":{"type":"string"},"value":{"description":"Secret value (write-only).","type":"string"}},"required":["key","value"],"title":"SecretRequest","type":"object"},"AgentRequest":{"properties":{"allowed_vault_ids":{"description":"Vaults a conversation may attach to this agent. null (default) allows any vault the tenant owns; an empty list forbids attaching any vault; a non-empty list is an allowlist.","items":{"format":"uuid","type":"string"},"nullable":true,"type":"array"},"description":{"type":"string"},"environment_id":{"format":"uuid","nullable":true,"type":"string"},"mcp_servers":{"additionalProperties":true,"type":"object"},"metadata":{"additionalProperties":true,"type":"object"},"model":{"pattern":"^[a-z0-9_-]+/[a-z0-9._-]+$","type":"string"},"name":{"maxLength":200,"minLength":1,"type":"string"},"runtime":{"enum":["claude","codex","gemini","opencode"],"type":"string"},"skills":{"description":"Each entry is either inline (`{name, content}` — full SKILL.md text written to the sprite) or github (`{source, ref?, name?}` — installed on the sprite via the skills.sh CLI, optionally pinned to a tag/branch/sha via `ref`). Exactly one of `content` or `source` must be set on each entry.","items":{"properties":{"content":{"description":"Full SKILL.md body for inline entries.","type":"string"},"name":{"description":"Skill name (required for inline entries).","type":"string"},"ref":{"description":"Optional tag, branch, or sha pinning a github-sourced skill (installed as `owner/repo@ref`). Without it the default branch is fetched at spawn time.","pattern":"^[A-Za-z0-9._/-]+$","type":"string"},"source":{"description":"GitHub `owner/repo` for skills.sh-sourced entries.","pattern":"^[A-Za-z0-9._/-]+$","type":"string"}},"type":"object"},"type":"array"},"system":{"type":"string"}},"required":["name","model","runtime"],"title":"AgentRequest","type":"object"},"EnvironmentRequest":{"properties":{"env_vars":{"additionalProperties":{"type":"string"},"type":"object"},"metadata":{"additionalProperties":true,"type":"object"},"name":{"maxLength":200,"minLength":1,"type":"string"},"networking_config":{"additionalProperties":true,"description":"Refines networking_type: limited. allowed_hosts is the only key honored today; unknown keys are ignored. Under limited, egress is restricted to the allowlisted domains. With no allowed_hosts (or an empty list), the sandbox denies all egress by default — this is a deny-all, not an allow-all.","properties":{"allowed_hosts":{"description":"Domains the sandbox may reach when networking_type is limited.","items":{"type":"string"},"type":"array"}},"type":"object"},"networking_type":{"enum":["unrestricted","limited"],"type":"string"},"packages":{"additionalProperties":true,"type":"object"},"repositories":{"items":{"$ref":"#/components/schemas/Repository"},"type":"array"},"setup_script":{"type":"string"}},"required":["name"],"title":"EnvironmentRequest","type":"object"},"Environment":{"description":"A reusable sandbox environment: packages, env vars, repos, networking.","properties":{"env_vars":{"additionalProperties":{"type":"string"},"type":"object"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"name":{"type":"string"},"networking_config":{"additionalProperties":true,"description":"Refines networking_type: limited. allowed_hosts is the only key honored today; unknown keys are ignored. Under limited, egress is restricted to the allowlisted domains. With no allowed_hosts (or an empty list), the sandbox denies all egress by default — this is a deny-all, not an allow-all.","properties":{"allowed_hosts":{"description":"Domains the sandbox may reach when networking_type is limited.","items":{"type":"string"},"type":"array"}},"type":"object"},"networking_type":{"enum":["unrestricted","limited"],"type":"string"},"packages":{"additionalProperties":true,"type":"object"},"repositories":{"items":{"$ref":"#/components/schemas/Repository"},"type":"array"},"setup_script":{"type":"string"},"updated_at":{"format":"date-time","type":"string"}},"required":["id","name"],"title":"Environment","type":"object"},"VaultListResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/Vault"},"type":"array"}},"required":["data"],"title":"VaultListResponse","type":"object"},"AgentResponse":{"properties":{"data":{"$ref":"#/components/schemas/Agent"}},"required":["data"],"title":"AgentResponse","type":"object"},"ApplyRequest":{"properties":{"resources":{"items":{"$ref":"#/components/schemas/ManifestResource"},"type":"array"}},"required":["resources"],"title":"ApplyRequest","type":"object"}},"securitySchemes":{"bearer":{"description":"ADMIN_TOKEN configured at boot.","scheme":"bearer","type":"http"}}},"info":{"description":"HTTP API for Agent on Demand. The same surface backs the LiveView UI\nand the `aod` CLI; if it's not here, it doesn't exist yet.\n\nAll `/api/*` endpoints require a bearer token (`ADMIN_TOKEN`).\n","title":"Agent on Demand","version":"0.3.0"},"openapi":"3.0.0","paths":{"/api/agents":{"get":{"callbacks":{},"operationId":"FountainWeb.AgentController.index","parameters":[{"description":"Case-insensitive substring match on the agent name.","in":"query","name":"search","required":false,"schema":{"type":"string"}},{"description":"Comma-separated runtimes, e.g. `claude,codex`.","in":"query","name":"runtime","required":false,"schema":{"type":"string"}},{"description":"Comma-separated environment ids.","in":"query","name":"environment_id","required":false,"schema":{"type":"string"}},{"description":"Only agents with at least one skill.","in":"query","name":"has_skills","required":false,"schema":{"type":"boolean"}},{"description":"Only agents with at least one MCP server.","in":"query","name":"has_mcp","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentListResponse"}}},"description":"Agents"}},"summary":"List agents","tags":["Agents"]},"post":{"callbacks":{},"operationId":"FountainWeb.AgentController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentRequest"}}},"description":"Agent attributes","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}},"description":"Agent"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Create an agent","tags":["Agents"]}},"/api/agents/{id}":{"delete":{"callbacks":{},"operationId":"FountainWeb.AgentController.delete","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Delete an agent","tags":["Agents"]},"get":{"callbacks":{},"operationId":"FountainWeb.AgentController.show","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}},"description":"Agent"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Get an agent","tags":["Agents"]},"patch":{"callbacks":{},"description":"Every field is optional; the server merges into the existing record.","operationId":"FountainWeb.AgentController.update (2)","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentUpdate"}}},"description":"Partial agent attributes","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}},"description":"Agent"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Update an agent (partial)","tags":["Agents"]},"put":{"callbacks":{},"description":"Every field is optional; the server merges into the existing record.","operationId":"FountainWeb.AgentController.update","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentUpdate"}}},"description":"Partial agent attributes","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}},"description":"Agent"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Update an agent (partial)","tags":["Agents"]}},"/api/apply":{"post":{"callbacks":{},"description":"Applies all resources from a compiled fountain.yml manifest in one request. Resources are reconciled by name — environments first, then vaults, then agents — so agent specs may reference an environment by name via `spec.environment`. Application is best-effort per resource: the response is 200 even when individual resources fail, with per-resource errors in the result entries.","operationId":"FountainWeb.ApplyController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplyRequest"}}},"description":"Compiled manifest","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplyResponse"}}},"description":"Per-resource results"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Apply a compiled manifest (bulk upsert)","tags":["Apply"]}},"/api/conversations":{"get":{"callbacks":{},"operationId":"FountainWeb.ConversationController.index","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationListResponse"}}},"description":"Conversations"}},"summary":"List conversations","tags":["Conversations"]},"post":{"callbacks":{},"description":"Creates a sandbox + conversation pair, starts the runtime in a fresh sprite, and (if `prompt` is supplied) sends it as turn 1. Pass `X-Fountain-Parent-Conversation-Id` header to record which conversation spawned this one. Legacy `X-AoD-Parent-Conversation-Id` is still accepted for sprites provisioned before the rename.","operationId":"FountainWeb.ConversationController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationCreateRequest"}}},"description":"Conversation attrs","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationResponse"}}},"description":"Conversation"},"402":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"},"upgrade_url":{"type":"string"}},"type":"object"}}},"description":"Subscription required"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Agent not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Start a conversation","tags":["Conversations"]}},"/api/conversations/{conversation_id}/interrupt":{"post":{"callbacks":{},"operationId":"FountainWeb.ConversationController.interrupt","parameters":[{"description":"","in":"path","name":"conversation_id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Interrupted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"No turn running"}},"summary":"Interrupt the running turn","tags":["Conversations"]}},"/api/conversations/{conversation_id}/prompts":{"post":{"callbacks":{},"description":"Queues a new turn. If the ConversationServer has been GC'd (e.g. across a BEAM restart) a fresh sprite is provisioned and the runtime resumes via its session id.","operationId":"FountainWeb.ConversationController.prompt","parameters":[{"description":"","in":"path","name":"conversation_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromptRequest"}}},"description":"Prompt","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromptResponse"}}},"description":"Queued"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Busy"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Send another prompt","tags":["Conversations"]}},"/api/conversations/{conversation_id}/stream":{"get":{"callbacks":{},"description":"Server-Sent Events stream of the conversation's log events. The `Last-Event-ID` request header resumes from a known event id; missed events are replayed before the live tail begins. Keep-alive heartbeats every 15s as `: heartbeat` comments.","operationId":"FountainWeb.ConversationController.stream","parameters":[{"description":"","in":"path","name":"conversation_id","required":true,"schema":{"type":"string"}},{"description":"Resume after this event id (integer as string). Missing or unparseable values are treated as 0.","in":"header","name":"Last-Event-ID","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of `stdout`, `stderr`, `stage`. Omitted or empty means all three.","in":"query","name":"streams","required":false,"schema":{"type":"string"}},{"description":"`false`/`0` drains the buffered events and closes immediately, rather than holding the connection open for the live tail. Defaults to true.","in":"query","name":"wait","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"text/event-stream":{"schema":{"type":"string"}}},"description":"SSE stream"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Stream log events (SSE)","tags":["Conversations"]}},"/api/conversations/{conversation_id}/terminate":{"post":{"callbacks":{},"description":"Tears down the sprite and marks the conversation `terminated`. Idempotent for already-dead conversations.","operationId":"FountainWeb.ConversationController.terminate","parameters":[{"description":"","in":"path","name":"conversation_id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Terminated"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Terminate a conversation","tags":["Conversations"]}},"/api/conversations/{conversation_id}/turns":{"get":{"callbacks":{},"operationId":"FountainWeb.ConversationController.turns","parameters":[{"description":"","in":"path","name":"conversation_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TurnListResponse"}}},"description":"Turns"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"List turns in a conversation","tags":["Conversations"]}},"/api/conversations/{id}":{"delete":{"callbacks":{},"description":"Tears down the sprite if alive, then deletes the conversation row (cascades to turns and log events).","operationId":"FountainWeb.ConversationController.delete","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Delete a conversation","tags":["Conversations"]},"get":{"callbacks":{},"operationId":"FountainWeb.ConversationController.show","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationResponse"}}},"description":"Conversation"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Get a conversation","tags":["Conversations"]}},"/api/environments":{"get":{"callbacks":{},"operationId":"FountainWeb.EnvironmentController.index","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentListResponse"}}},"description":"Environments"}},"summary":"List environments","tags":["Environments"]},"post":{"callbacks":{},"operationId":"FountainWeb.EnvironmentController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRequest"}}},"description":"Environment attributes","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentResponse"}}},"description":"Environment"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Create an environment","tags":["Environments"]}},"/api/environments/{environment_id}/secrets":{"get":{"callbacks":{},"operationId":"FountainWeb.SecretController.index","parameters":[{"description":"","in":"path","name":"environment_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SecretListResponse"}}},"description":"Secrets"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"List secrets in an environment","tags":["Secrets"]},"post":{"callbacks":{},"description":"Sets the value for `key`. If the key exists, the value is overwritten. Values are write-only — subsequent reads never return them.","operationId":"FountainWeb.SecretController.create","parameters":[{"description":"","in":"path","name":"environment_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SecretRequest"}}},"description":"Secret","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SecretResponse"}}},"description":"Secret"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Upsert a secret","tags":["Secrets"]}},"/api/environments/{environment_id}/secrets/{id}":{"delete":{"callbacks":{},"operationId":"FountainWeb.SecretController.delete","parameters":[{"description":"","in":"path","name":"environment_id","required":true,"schema":{"type":"string"}},{"description":"Secret key.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Delete a secret by key","tags":["Secrets"]}},"/api/environments/{id}":{"delete":{"callbacks":{},"operationId":"FountainWeb.EnvironmentController.delete","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Delete an environment","tags":["Environments"]},"get":{"callbacks":{},"operationId":"FountainWeb.EnvironmentController.show","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentResponse"}}},"description":"Environment"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Get an environment","tags":["Environments"]},"patch":{"callbacks":{},"description":"Every field is optional; the server merges into the existing record.","operationId":"FountainWeb.EnvironmentController.update (2)","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentUpdate"}}},"description":"Partial environment attributes","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentResponse"}}},"description":"Environment"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Update an environment (partial)","tags":["Environments"]},"put":{"callbacks":{},"description":"Every field is optional; the server merges into the existing record.","operationId":"FountainWeb.EnvironmentController.update","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentUpdate"}}},"description":"Partial environment attributes","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentResponse"}}},"description":"Environment"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Update an environment (partial)","tags":["Environments"]}},"/api/vaults":{"get":{"callbacks":{},"operationId":"FountainWeb.VaultController.index","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultListResponse"}}},"description":"Vaults"}},"summary":"List vaults","tags":["Vaults"]},"post":{"callbacks":{},"operationId":"FountainWeb.VaultController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultRequest"}}},"description":"Vault attributes","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultResponse"}}},"description":"Vault"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Create a vault","tags":["Vaults"]}},"/api/vaults/{id}":{"delete":{"callbacks":{},"operationId":"FountainWeb.VaultController.delete","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Delete a vault","tags":["Vaults"]},"get":{"callbacks":{},"operationId":"FountainWeb.VaultController.show","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultResponse"}}},"description":"Vault"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Get a vault","tags":["Vaults"]},"patch":{"callbacks":{},"description":"Every field is optional; the server merges into the existing record.","operationId":"FountainWeb.VaultController.update (2)","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultUpdate"}}},"description":"Partial vault attributes","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultResponse"}}},"description":"Vault"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Update a vault (partial)","tags":["Vaults"]},"put":{"callbacks":{},"description":"Every field is optional; the server merges into the existing record.","operationId":"FountainWeb.VaultController.update","parameters":[{"description":"","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultUpdate"}}},"description":"Partial vault attributes","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultResponse"}}},"description":"Vault"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Update a vault (partial)","tags":["Vaults"]}},"/api/vaults/{vault_id}/secrets":{"get":{"callbacks":{},"operationId":"FountainWeb.VaultSecretController.index","parameters":[{"description":"","in":"path","name":"vault_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultSecretListResponse"}}},"description":"Vault Secrets"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"List secrets in a vault","tags":["Vault Secrets"]},"post":{"callbacks":{},"description":"Sets the value for `key`. If the key exists, the value is overwritten. Values are write-only — subsequent reads never return them.","operationId":"FountainWeb.VaultSecretController.create","parameters":[{"description":"","in":"path","name":"vault_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultSecretRequest"}}},"description":"Vault Secret","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VaultSecretResponse"}}},"description":"Vault Secret"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangesetError"}}},"description":"Validation error"}},"summary":"Upsert a vault secret","tags":["Vault Secrets"]}},"/api/vaults/{vault_id}/secrets/{id}":{"delete":{"callbacks":{},"operationId":"FountainWeb.VaultSecretController.delete","parameters":[{"description":"","in":"path","name":"vault_id","required":true,"schema":{"type":"string"}},{"description":"Secret key.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Not found"}},"summary":"Delete a vault secret by key","tags":["Vault Secrets"]}},"/health":{"get":{"callbacks":{},"description":"Public, unauthenticated. Returns `{\"status\": \"ok\"}` if the app is up. Checks no dependencies by design — ask `/health/ready` whether this instance can actually serve.","operationId":"FountainWeb.HealthController.show","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}},"description":"Health response"}},"security":[],"summary":"Liveness probe","tags":["Health"]}},"/health/ready":{"get":{"callbacks":{},"description":"Public, unauthenticated. Returns 200 when this instance can serve requests, or 503 when a dependency it cannot work without is unavailable. Individual checks report `ok` or `error` and nothing further.","operationId":"FountainWeb.HealthController.ready","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReadinessResponse"}}},"description":"Ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReadinessResponse"}}},"description":"Not ready"}},"security":[],"summary":"Readiness probe","tags":["Health"]}}},"security":[{"bearer":[]}],"servers":[{"url":"http://localhost:4000","variables":{}}],"tags":[]}