19 KiB
Built-In Profiles And Library API Implementation Plan
This plan implements the target states in:
docs/roadmap/builtins.mddocs/roadmap/library.md
Audience: LLM coding agents implementing the work in order. Review and follow docs/policy/architecture.md, docs/policy/development.md, and docs/policy/documentation.md before changing code.
Global Constraints
- Implement built-in profiles before the public library production upgrades.
- Keep orchestration in
internal/usecase; adapters and the public package should translate inputs and wire components. - Keep
internal/*packages internal. Public package types must remain facade types. - Do not add a mutable global profile registry.
- Do not add a credential resolver or secret-manager abstraction.
- Do not accept raw API keys in YAML/JSON config, profile files, prompt files, CLI flags, or HTTP request bodies.
- Do not emit raw API keys in prepared output, run results, logs, or examples.
- Prefer standard library APIs. Do not add dependencies unless a later implementation prompt explicitly approves one.
- Keep each stage passing
go test ./...before moving to the next stage.
Stage 1: Profile Repository Foundations
Goal: add reusable profile repository primitives that support built-ins without changing runner behavior.
Implementation Steps
-
Add an
fs.FS-backed profile repository ininternal/profile.- Constructor shape should be similar to
NewFSRepository(fsys fs.FS, root string) Repository. - It must scan YAML files recursively below
root. - It must use the same strict YAML decoding, profile validation, raw
api_keyrejection, and duplicate-ID behavior as the existing filesystem repository. - It must return the existing profile package sentinel errors where applicable.
- Constructor shape should be similar to
-
Refactor shared profile-loading behavior.
- Avoid duplicating validation and metadata logic between filesystem and
fs.FSrepositories. - The existing
NewFilesystemRepository(dir)API should remain available. - It may either delegate to the
fs.FSrepository throughos.DirFSor share unexported loader helpers.
- Avoid duplicating validation and metadata logic between filesystem and
-
Add an overlay profile repository in
internal/profile.- Constructor shape should be similar to
NewOverlayRepository(primary, fallback Repository) Repository. - Lookup must return the primary result when primary succeeds.
- Lookup must fall back only when
errors.Is(err, profile.ErrProfileNotFound)for the primary. - Lookup must return primary load/validation errors directly and must not fall back after those errors.
- Nil repository inputs should be handled deliberately. Prefer treating nil primary as "no primary" and requiring a non-nil fallback for built-in-only operation.
- Constructor shape should be similar to
Tests
Add focused tests under internal/profile.
Required coverage:
NewFSRepositoryloads valid profiles from nested directories.NewFSRepositoryrejects unknown YAML fields.NewFSRepositoryrejects rawapi_keyin the selected profile.NewFSRepositoryignores rawapi_keyin non-selected profiles, matching current filesystem behavior.NewFSRepositoryrejects duplicate IDs within one source.NewFilesystemRepositorystill satisfies all existing repository tests.NewOverlayRepositoryreturns primary matches before fallback matches.NewOverlayRepositoryfalls back on primary not found.NewOverlayRepositorydoes not fall back after a primary invalid YAML/profile/raw-key error.NewOverlayRepositoryreturns not found when both sources miss.
Verification
Run:
go test ./internal/profile
go test ./...
Stage 2: Built-In Profile Assets And Wiring
Goal: compile built-in profiles into Scriptorium and make profile lookup use user-over-built-in precedence.
Implementation Steps
-
Add an
internal/profile/builtinpackage.- Store built-in profile YAML files in a stable asset directory under that package.
- Use Go
embedto compile those files into the binary/package. - Expose a constructor such as
builtin.NewRepository() profile.Repository. - The repository should use the
fs.FSprofile repository from Stage 1.
-
Add built-in profile validation tests.
- Tests should load every built-in profile through the real profile loader.
- Tests should fail if the built-in profile set contains duplicate IDs.
- Tests should fail if any built-in profile contains raw
api_key.
-
Add built-in profiles.
- Use
docs/roadmap/profiles/as the source catalog for the initial built-in profile set. - Copy those YAML files into the built-in profile asset directory, preserving provider subdirectories unless the implementation has a clear reason to flatten them.
- Do not invent a broad provider/model catalog.
- Do not add raw API keys.
- Built-in profiles may use
api_key_envfor CLI/HTTP compatibility when the provider requires authentication.
- Use
-
Wire repositories through a small helper.
- Add an internal helper near adapter wiring, or in
internal/profile, that returns:- built-in repository only when no custom profile source is configured;
- overlay repository when a custom profile source is configured.
- The runner should still receive only a
profile.Repository.
- Add an internal helper near adapter wiring, or in
-
Make
profile_diroptional.- CLI
run, CLIrender, and HTTPserveargument/config validation should requireprompt_dirbut no longer requireprofile_dir. - Public
NewEngineshould no longer reject an emptyConfig.ProfileDir. - When
profile_diris empty, wire only built-ins. - When
profile_diris non-empty, wire filesystem profiles over built-ins.
- CLI
Tests
Add or update tests under internal/adapter/cli, internal/adapter/http, root public package tests, and profile/builtin tests.
Required coverage:
- CLI parse/config tests accept missing
profile_dirwhenprompt_diris present. - HTTP serve parse/config tests accept missing
profile_dirwhenprompt_diris present. - Public
NewEngineaccepts missingProfileDir. - A built-in profile ID can be selected with no custom
profile_dir. - A prompt
default_profilecan refer to a built-in profile ID. - A custom profile in
profile_diroverrides a built-in with the same ID. - A malformed selected custom profile does not fall back to a built-in with the same ID.
- A missing profile ID still maps to the existing profile-not-found behavior.
- Existing duplicate-ID tests for filesystem profiles continue to fail within the custom source.
Documentation
After code behavior exists, update non-roadmap docs:
docs/config.md: documentprofile_diras optional and describe built-in fallback/override behavior.docs/cli.md: remove claims that--profile-diris required.docs/internal/adapters.md: document built-in profile repository composition.- Any affected examples or README snippets that imply
profile_diris mandatory.
Do not document built-in profile IDs outside implemented assets.
Verification
Run:
go test ./internal/profile ./internal/adapter/cli ./internal/adapter/http .
go test ./...
go run ./cmd/scriptorium render \
--config ./examples/config.yml \
--prompt generic.markdown_summary \
--input transcript=./examples/fixtures/transcript.md \
--input glossary=./examples/fixtures/glossary.yml \
--format json
Also run a new smoke command that uses a built-in profile without --profile-dir once a concrete built-in profile ID is available.
Stage 3: Public API Credential Value
Goal: support the production library credential model: a direct API-key Go value, without adding a resolver or accepting raw keys in serialized config.
Public API Decision
Use a single public credential-supply method:
type RunRequest struct {
// existing fields...
APIKey string `json:"-"`
}
Do not add Config.APIKey, WithAPIKey, or a credential resolver in this stage. A request-level value avoids storing secrets on long-lived engines and supports per-tenant callers.
Implementation Steps
-
Add direct API-key plumbing through internal request/target types.
- Add an internal direct API-key field where needed, with
json:"-"andyaml:"-"tags. - Convert public
RunRequest.APIKeyinto the internal request. - Carry the value to the effective execution target used for LLM generation.
- Do not include the value in prepared/run public results, formatted output, logs, hashes, or docs examples.
- Add an internal direct API-key field where needed, with
-
Update credential validation.
- If a selected/effective profile requires authentication and a direct API key is provided, do not require the environment variable to be set for the public path.
- Preserve existing CLI/HTTP behavior that uses
api_key_env. - Preserve existing errors for missing environment variables in CLI/HTTP paths.
-
Update the OpenAI-compatible LLM client.
- Prefer the direct API-key value when present.
- Fall back to existing
api_key_envbehavior for CLI/HTTP compatibility. - Never serialize or log the direct API-key value.
-
Update public LLM injection conversion.
- Do not expose the raw API key to injected public
LLMClientimplementations unless that is strictly necessary for custom LLM execution. - If custom LLM clients need the key, expose it only on the public
GenerateRequestwithjson:"-"and document that fake/test clients should avoid logging it.
- Do not expose the raw API key to injected public
Tests
Required coverage:
- Public
Runcan call the default OpenAI-compatible client path with a direct API key without requiring the configuredapi_key_envenvironment variable. - CLI/HTTP behavior using
api_key_envstill works. - Missing credentials still fail clearly when a selected profile requires authentication and neither direct key nor usable env value is available.
- Public
PreparedRunandRunResultJSON do not include the direct API key. - Formatted prepared output does not include the direct API key.
- Direct API-key values are not included in hashes.
- Injected fake LLM tests either receive no key or receive it only through a
json:"-"field, depending on the implementation choice above.
Verification
Run:
go test ./internal/llm ./internal/usecase ./internal/adapter/cli ./internal/adapter/http .
go test ./...
Stage 4: Public Asset Source Options
Goal: let library consumers load standard Scriptorium prompt/profile/schema assets from directories, single files, and fs.FS sources.
Public API
Keep existing Config directory fields working. Add options:
func WithPromptFS(fsys fs.FS, root string) Option
func WithPromptFile(path string) Option
func WithProfileFS(fsys fs.FS, root string) Option
func WithProfileFile(path string) Option
func WithSchemaFS(fsys fs.FS, root string) Option
func WithSchemaFile(path string) Option
Rules:
- Directory
Configfields remain the compatibility path. - Explicit options override the corresponding
Configdirectory field. - Prompt source is required.
- Profile source is optional because built-in profiles exist.
- Schema source is optional and should default to the current schema default behavior when not configured.
- Nil
fs.FSvalues, empty required roots, and invalid option combinations returnErrInvalidConfig.
Implementation Steps
-
Add
fs.FSprompt-definition support.- Implement an
fs.FSprompt repository that preserves existing strict prompt YAML behavior. - Preserve prompt lookup by YAML
id, not path. - Preserve duplicate prompt ID errors within one source.
- Resolve
content_filerelative to the prompt file's directory inside the same source. - Keep the existing filesystem repository API; it may delegate to the
fs.FSimplementation.
- Implement an
-
Add public prompt source wiring.
Config.PromptDirwires the filesystem repository.WithPromptFSwires thefs.FSrepository.WithPromptFile(path)wires a single-file source and must still select by prompt YAMLid.
-
Add public profile source wiring.
- Reuse the Stage 1 profile
fs.FSrepository. WithProfileFSandWithProfileFilebecome overlay primaries above built-ins.- Empty profile source still means built-ins only.
- Reuse the Stage 1 profile
-
Add schema
fs.FSsupport.- Extend or wrap the standard validator so schema files can be loaded from an
fs.FSsource. WithSchemaFSshould preserve existingschema_pathsemantics.WithSchemaFile(path)should expose the file by its base name; prompts using it should setschema_pathto that base name.
- Extend or wrap the standard validator so schema files can be loaded from an
-
Keep adapter scope narrow.
- This stage is for the public package and shared repositories/validators.
- Do not change CLI/HTTP request shapes for prompt or schema
fs.FSsources.
Tests
Required coverage:
- Public
Prepareworks with prompt definitions fromembed.FS. content_filereferences resolve relative to the prompt file inembed.FS.- Public
Prepareworks withWithPromptFile. - Public
RunorPrepareworks withWithProfileFSover built-ins. - Public
RunorPrepareworks withWithProfileFileover built-ins. - Public structured-output schema validation works with
WithSchemaFS. WithSchemaFileworks when the prompt'sschema_pathis the schema file base name.- Explicit source options override
Configdirectory fields. - Invalid/nil source options return
ErrInvalidConfig. - Existing filesystem prompt/profile/schema behavior remains unchanged.
Verification
Run:
go test ./internal/promptdef ./internal/profile ./internal/validate .
go test ./...
Stage 5: Public In-Memory Profiles And Profile Templates
Goal: allow library callers to provide typed profile values and construct stable OpenAI-compatible profile templates without generating YAML.
Public API
Add a public profile facade aligned with the profile YAML contract:
type Profile struct {
ID string
Endpoint string
Model string
Temperature float64
MaxTokens int
TopP float64
TimeoutSeconds int
ServiceTier string
ReasoningEffort string
APIKeyRequired bool
ExtraParams map[string]any
}
func WithProfiles(profiles ...Profile) Option
Add an OpenAI-compatible template constructor:
type OpenAICompatibleProfileConfig struct {
ID string
Endpoint string
Model string
APIKeyRequired bool
Temperature float64
MaxTokens int
TopP float64
TimeoutSeconds int
ServiceTier string
ReasoningEffort string
ExtraParams map[string]any
}
func OpenAICompatibleProfile(cfg OpenAICompatibleProfileConfig) Profile
Rules:
WithProfilesprofiles override built-ins with the same ID.- If
WithProfilesand a file/FS profile source are both configured, in-memory profiles have highest precedence, then file/FS profiles, then built-ins. - Public profile values must not include raw API-key fields.
- Use
APIKeyRequiredto indicate whetherRunRequest.APIKeyis required for the public path. Internal conversion may map this to the existing auth-required/profile credential model without exposing raw keys.
Implementation Steps
-
Add a profile repository for public in-memory
Profilevalues.- Validate with the same effective rules as YAML profiles.
- Reject duplicate IDs within the provided values.
- Deep-copy
ExtraParamsacross public/internal boundaries.
-
Compose profile sources in public
NewEngine.- Highest: in-memory public profiles.
- Next: configured profile file/FS/directory source.
- Fallback: built-in profiles.
-
Add template constructor conversion.
OpenAICompatibleProfileshould be a convenience constructor only.- It should not register global state.
- It should not maintain a broad model catalog.
-
Ensure direct API-key behavior works with in-memory/template profiles.
- Profiles marked
APIKeyRequiredshould requireRunRequest.APIKeyfor public default LLM execution. - Profiles not marked
APIKeyRequiredshould not require an API key.
- Profiles marked
Tests
Required coverage:
- Public
Prepare/RunusesWithProfileswithout any profile files. - In-memory profiles override built-ins.
- In-memory profiles override file/FS profile sources when IDs collide.
- Duplicate in-memory profile IDs return
ErrInvalidConfig. - Template-created profiles execute through the same path as normal profiles.
- Template-created profiles requiring an API key work with
RunRequest.APIKey. - Template-created profiles that do not require an API key work without one.
ExtraParamsin public profiles are deep-copied and isolated from caller mutation.
Verification
Run:
go test .
go test ./...
Stage 6: Public Documentation And Examples
Goal: document implemented behavior in canonical locations after code exists.
Documentation
Update:
README.md: add a short pointer to library usage and built-in profiles without turning the README into a manual.docs/config.md: document optionalprofile_dir, built-in override behavior, and implemented built-in profile IDs.docs/cli.md: document optional--profile-dirand built-in profile selection.docs/internal/adapters.md: document repository composition and public library adapter surface.docs/consumers/api.md: describe public consumer surfaces at a high level.docs/consumers/pkg-scriptorium.md: document root package usage, source options, direct API-key value, errors, and examples.docs/integrations/openai-compatible-chat.md: update only if direct API-key plumbing changes provider-call semantics.
Do not document unimplemented roadmap behavior outside docs/roadmap/.
Examples
Add or update copyable examples that require no real provider credentials:
- prepare with built-in profile and no
profile_dir; - public library prepare from
embed.FS; - public library run with injected fake LLM;
- public library run with typed/template profile and direct API key, using a fake/local provider path so no real secret is required.
Examples must be secret-free.
Tests And Smoke Commands
Required verification:
go test ./...
go run ./cmd/scriptorium render \
--config ./examples/config.yml \
--prompt generic.markdown_summary \
--input transcript=./examples/fixtures/transcript.md \
--input glossary=./examples/fixtures/glossary.yml \
--format json
Also run smoke commands for any new examples, such as:
go run ./examples/go-library/prepare
If an example relies on a specific built-in profile ID, use an ID from docs/roadmap/profiles/ and include that smoke command in the implementing change.
Final Completion Checklist
Before marking the combined feature complete:
go test ./...passes.- Existing CLI render smoke command passes.
- At least one smoke command proves built-in profile lookup works without
profile_dir. - Public package examples compile/run without real provider credentials.
profile_diris optional in CLI, HTTP, and public engine construction.- Custom profiles override built-ins with the same ID.
- Malformed selected custom profiles do not fall back to built-ins.
- Built-in profiles use the same validation rules as file profiles.
- Public
RunRequest.APIKeyis the documented library credential method. - Raw API keys do not appear in YAML/JSON config, prepared output, run results, logs, hashes, or examples.
- Non-roadmap docs describe only implemented behavior.