Config file
graft start reads a YAML file, graft.yml by default. The file is a list of
entries, and each entry loads one plugin:
- id: database name: "@acme/graft-plugin-postgres" config: url: !env DATABASE_URL
- id: api name: ./plugins/api.mjs config: port: 8080
- id: tenants name: tenants group: true isolate: app/Database: true config: - id: tenant-db name: "@acme/graft-plugin-postgres" config: url: !env TENANT_DATABASE_URL| This page covers | Read |
|---|---|
| The format of an entry | Entry fields |
How name finds a module |
Plugin names |
| Secrets and settings from the environment | Environment variables |
| Editor completion and checks | Editor support |
| What happens when you save the file | Changes while running |
| Editing entries from plugins or code | Loader API |
An entry can also run its plugin in a child process, a worker or on another
machine: give it name: remote and wrap the plugin’s config, as in
Load from YAML. It is still an ordinary
entry, with an id, disabled, inject, isolate and edits while running. Its
saved id also names the runner instance.
Plugins loaded from an entry can read yield* Entry.CurrentId, importing
Entry from @graftland/loader. This Context.Reference<Option<string>> holds
that entry’s id, including inside nested groups, and defaults to None outside
entry loading. Child plugins forked by the plugin inherit it; it is not a new id
for every fork. Include persists generated ids in YAML, including plugin entry
lists, so they survive host restarts. Outside a file, missing ids derive from
the parent id, the plugin name and its occurrence among same-name entries
without ids.
Command plugins
Section titled “Command plugins”Declare command plugins as top-level entries:
- name: remote/cli cli: trueThese entries configure CLI commands, not application plugins. graft start
leaves them out of the application tree. Their config is passed to the command
plugin; disabled: true disables them. Restart the command to apply changes.
Command entries read !env from the process environment; start --env-file and
--env-dir apply to application entries.
| Source | Rule |
|---|---|
| Project | graft.yml in the current directory; no ancestor search. |
| User | ~/.graft/graft.yml, with packages installed in ~/.graft/node_modules. |
| Override | GRAFT_HOME replaces ~/.graft, including both config and packages. |
| Merge | Exact name matches use the project entry, including disabled; other entries are combined. Conflicting command names are errors. |
| Outside a project | User commands still work; a missing file contributes no entries. |
| Paths | Modules resolve from the declaring file’s directory. Plugins can use ConfigDirectory for their configuration paths. |
Only explicitly declared modules run; Graft does not scan installed
dependencies. Use graft add --plugin to install and
enable one.
Entry fields
Section titled “Entry fields”| Field | What it does |
|---|---|
id |
Identifies the entry. If an entry has none, Graft adds one and writes it to the file, including inside a plugin’s entry lists. |
name |
The plugin’s module: a relative path, a package name, or a short name. Groups ignore it. |
config |
The plugin’s input. For a group, its list of entries. |
disabled |
true unloads the entry but keeps it in the file. |
group |
true makes the entry a group of other entries. |
inject |
Service keys to wait for before loading the plugin, in addition to its own inject. |
isolate |
Keys to isolate: true for a private realm, or a label for a shared one. |
intercept |
Schema-checked patches for registered references, applying to this entry and its descendants. |
| Rule | Detail |
|---|---|
| Unique IDs | IDs must be unique across the entire Loader/Include tree, including disabled entries. Plugin entry lists and directly embedded Group.Group instances require uniqueness only among siblings. |
| Same at every level | Entry fields are decoded the same way at every nesting level. |
| Extra fields | Fields such as note are kept as metadata, also when you edit through the API. They do not skip the checks on declared fields. |
| Empty groups | A group without config is an empty group (config: []). A group’s config cannot be an explicit null. |
| Keys | Service and reference keys are the strings their classes were created with, such as app/Database for Context.Service<...>()("app/Database"). |
Keeping identity across edits
Section titled “Keeping identity across edits”Plugin entry lists keep their written ids when entries are inserted or reordered. Outside a file, use explicit ids before inserting or moving same-name siblings.
If you rewrite an anonymous tree and nothing changed, it keeps its assigned IDs and its running plugin instances.
- The loader restores the child IDs.
- It matches the anonymous parent groups.
- It writes the IDs back to YAML.
Anonymous entries are matched by name and a conservative config fingerprint, then compared exactly. Duplicate matches retain their order.
Intercepts in a config file
Section titled “Intercepts in a config file”Config-file intercepts accept only
explicitly registered references.
graft start registers two:
| Reference key | Configured value | Behavior |
|---|---|---|
effect/References/CurrentLogAnnotations |
An object | Shallowly merges with outer annotations; configured keys win. |
effect/References/MinimumLogLevel |
All, Fatal, Error, Warn, Info, Debug, Trace, or None |
Replaces the outer minimum log level. Values are case-sensitive. |
- id: api name: ./plugins/api.mjs intercept: effect/References/CurrentLogAnnotations: component: api effect/References/MinimumLogLevel: WarnThis entry and its child plugins inherit the annotations and the log level.
Sibling entries keep their own settings. Here, Info, Debug and Trace logs
are filtered out; All allows every level, and None turns off all logs.
| Problem | Error |
|---|---|
| Key not registered | UnregisteredReference |
| Invalid value | InvalidIntercept |
When you embed the loader, the registry is empty unless the host application provides one at bootstrap.
Plugin names
Section titled “Plugin names”How name is resolved:
name looks like |
Meaning |
|---|---|
Starts with . |
A path relative to the config file. |
Starts with / |
An absolute path. |
Starts with @, or contains : |
Used as it is. |
Anything else (such as review) |
Tried as written, then as @graftland/plugin-<name> and graft-plugin-<name>; a module as written that exports no plugin, such as Node’s http, is skipped. The two conventions rank equally: if both are installed, the entry fails with AmbiguousPluginName. Both are imported to decide, so if the second fails to import, the entry fails with that error. |
Packages are resolved from the application directory. For example,
name: remote finds @graftland/plugin-remote, and name: remote/runner finds
its runner export.
When a module cannot be loaded
Section titled “When a module cannot be loaded”If a plugin cannot be found, or its module fails to import, the entry still appears in the fork tree as a failed fork with the error. Graft tries again when the entry changes, or when hot reload picks up new code.
Entry lifecycle
Section titled “Entry lifecycle”Every enabled entry gets a lifecycle fork before its module starts resolving.
| Situation | Behavior |
|---|---|
| Import still pending | Counts as Loading. ready keeps waiting, but healthy siblings and later edits can go on. |
| Entry removed, disabled or replaced | Its lifecycle is cancelled and resolver resources are released. |
| Config changes during resolution | A new attempt starts. Unchanged entries keep their current attempt. |
| Late result from a cancelled import | Cannot activate the old entry again. The native import promise itself may still run module code. |
| Resolution succeeds | The lifecycle creates the real plugin as a child fork. ready checks this whole subtree. |
Pending service injections and failed forks still count as settled.
Effect importers acquire resources in a separate scope for each module candidate:
- A failed candidate is released before trying the next name.
- A cleanup failure prevents fallback and is kept together with the import failure.
- A successful candidate’s resources remain owned by the entry lifecycle.
Environment variables
Section titled “Environment variables”!env NAME reads an environment variable when the entry is loaded.
- The file keeps the tag, so secrets are never written into it.
- If the variable is not set, the entry fails until you fix it.
graft start --env-file .envalso reads variables from a.envfile; the process environment wins.--env-dirreads mounted secret files the same way. See the command line.
Types and secrets
Section titled “Types and secrets”!env gives a string. The plugin’s config schema decides what it becomes. The
person who writes the config file chooses which variable to read; the plugin
only describes the value it needs.
| Schema | Result |
|---|---|
Schema.NumberFromString |
A number. |
Schema.RedactedFromValue(Schema.String) |
A secret that logs hide. |
A plugin that wants Effect’s Config tools, such as defaults and fallbacks, can
use them inside apply to read its own input:
import { Config, ConfigProvider, Effect, Schema } from "effect"
import { Plugin } from "@graftland/graft"
const Settings = Config.all({ port: Config.Port("port").pipe(Config.withDefault(8080)),})
const readSettings = (input: unknown) => Settings.parse(ConfigProvider.fromUnknown(input))
export default Plugin.make({ name: "app/server", config: Schema.Record(Schema.String, Schema.Unknown), apply: (input) => Effect.gen(function* () { const settings = yield* readSettings(input) yield* Effect.log(`Listening on port ${settings.port}`) }),})
Effect.runSync(readSettings({ port: "3000" })) // => { port: 3000 }Effect.runSync(readSettings({})) // => { port: 8080 }The plugin’s schema still describes what the entry accepts. Config values that
a plugin reads from the ambient provider by itself are not !env tags, so
retry does not know about them and does not reload them.
Where tags work
Section titled “Where tags work”- In ordinary plugin
configand ininterceptvalues, also inside nested arrays and records. - An entry resolves its intercept first. The registered schema then decodes and merges it, and the resolved values must still pass that schema.
- A group resolves its own intercept, not the descriptions of its descendants, and it does not resolve its children’s variables early.
- Disabled entries do not resolve variables.
- If an active child lacks a variable, only that child fails. Its group and siblings are not affected.
- The saved target and the file keep the original tags.
When values are read again
Section titled “When values are read again”| Trigger | Which entries read variables again |
|---|---|
| Ordinary API edit or YAML change | Only changed entries. Unchanged healthy siblings and subtrees keep their values. |
loader.retry or loader.root.update(entries) |
The whole tree, including nested groups and injection gates. |
After an explicit refresh:
- A running entry whose resolved config changed gets the new config through
update. - An entry whose resolved intercept changed is loaded again.
- Entries whose values did not change keep running.
- An entry that is still importing reads the environment when the import ends. If a retry reaches an entry that is still loading, it reads them again once it is active.
readywaits for these queued and executing refreshes too, so after an explicit refresh andready, every active entry has the current values.
Cycles
Section titled “Cycles”Arrays and plain records may share config values, but they cannot contain cycles.
| Where the cycle is | Result |
|---|---|
| In a config or intercept | The entry fails with CyclicConfig. Siblings and later edits can go on. |
| In the entry/group structure | Rejected with InvalidOperation, before ID assignment and loading. |
Environment resolution treats other class instances as opaque.
Editor support
Section titled “Editor support”graft schema writes graft.schema.json, the JSON
Schema of this file for the plugins your project installs. Point the YAML
language server to it with a comment on the first line, and editors such as VS
Code (with the YAML extension) can complete and check the entries:
# yaml-language-server: $schema=./graft.schema.json- id: api name: ./plugins/api.mjs| Schema | Checks | Kept current by |
|---|---|---|
graft.schema.json |
Entry fields, registered intercepts, and the config of every installed plugin that exports a registration, in groups too. |
graft create, and graft add and graft remove when the file exists. |
@graftland/cli/schema.json, released with every version |
Entry fields and registered intercepts. The config of every plugin, Graft’s own included, stays open. |
Its version, as in https://cdn.jsdelivr.net/npm/@graftland/cli@1.2.3/schema.json. |
graft createpointsgraft.ymlto./graft.schema.json, and writes the file once dependencies are installed.- Commit
graft.schema.json, so editors check entries right after a clone. - Run
graft schemayourself after installing or upgrading packages with the package manager directly. - Config without a registration stays open; its plugin’s schema checks it at startup.
The language server may flag !env. Declare the tag in the editor settings. For
VS Code:
{ "yaml.customTags": ["!env scalar"] }Changes while running
Section titled “Changes while running”With graft start --watch, Graft watches the config file. Include merges
notifications from the entry and physical-target watchers and waits for 100 ms
without another notification before refreshing. Installing a watcher still
requests a catch-up read, including after a symlink target changes.
Graft then compares the new entries with the running entries:
| Change | Result |
|---|---|
config changed |
New input through update. The plugin updates in place or restarts. |
name, group, inject, isolate or intercept changed |
Unloaded and loaded again. |
| New entry | Loaded. |
| Removed or disabled entry | Unloaded. |
| Unchanged healthy entry | Keeps running with its existing environment values. |
| Failed entry in a reconciled group | Retried. Unchanged resolutions still in progress keep their activation. |
Concurrency
Section titled “Concurrency”- Independent sibling updates and removals run concurrently.
- Work for each entry remains serialized, and replacement waits for that entry’s old cleanup.
- The group waits for all required work before accepting the target.
- Delayed environment refreshes share the group’s serial reconciliation executor with ordinary edits. They cannot interrupt an update callback to apply a newer target.
- Requests for removed or superseded entry generations are discarded.
Cleanup failures
Section titled “Cleanup failures”If cleanup fails while Graft removes, disables or replaces an entry, Graft logs the complete cause with the entry and fork identifiers, then continues reconciling.
- The removed fork is disposed. A replacement has its own separate lifecycle.
- If a restart fails on a fork that stays, the failure remains visible as
Failed, and you can retry it. - Failures of resolver observation resources and lifecycle cleanup are recorded together, without restarting healthy siblings.
readycounts failed forks as settled. It does not promise that cleanup worked.
Invalid files
Section titled “Invalid files”| Situation | Behavior |
|---|---|
| Invalid file while running | Graft logs the error and keeps the last valid version. |
Invalid first file, with --watch |
Graft logs the cause, starts with no entries and keeps watching. Fixing the file loads its entries. |
Invalid first file, without --watch |
Startup fails. |
| API edit while the file is invalid | Cannot replace the invalid file with an empty configuration. |
When embedding Include with initial, a missing file is created only after YAML
preparation, ID assignment, full-tree validation and serialization verification
succeed. The complete document is committed once. Invalid initial entries leave
the file absent, and an existing file takes precedence over initial.
Symbolic links
Section titled “Symbolic links”Include preserves symbolic links. Each disk operation resolves the configured path and keeps that real target with the content snapshot. Reads, conflict checks, automatic ID writes and API edits use that same target; temporary files are created in its directory.
| Situation | Behavior |
|---|---|
| Link changes before rename | The temporary file is discarded and the edit is rebased with the bounded retry and same-field conflict rules. |
| Watch mode | Include watches both the entry directory and the current target directory. |
| Entry link changes | A fresh read, and the target watcher switches: the old one is released, and the new one requests another read when ready. |
| New target has invalid YAML | The last valid runtime target stays; fixing the file recovers it. |
| Without watch | The next API edit reads the new referent. |
| Intermediate links outside watched directories | Followed on the next read, but they generate no notification themselves. |
| Missing regular file | Can be initialized. |
| Dangling, cyclic or inaccessible link | A typed filesystem error. It is never replaced with a regular file. Repair the referent; an API edit or entry notification retries. |
Managed integrations
Section titled “Managed integrations”A managed Remote entry uses artifact and target instead of implementation
and connect:
- id: forecasts name: "@graftland/plugin-remote" config: contract: "@example/forecast-contract" artifact: directory: ./artifacts/sha256-v1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa identity: version: 1 algorithm: sha256 digest: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa entry: dist/index.js target: _tag: Worker probes: [health] maxInFlight: 16 config: {}Replace the example artifact with the directory and identity returned by
Artifact.capture. The artifact directory and relative
contract locations resolve from the configuration file’s directory. Use
target: { _tag: Local } for a child process.
The entry id is the managed instance name: request
Graft.transition({ target: "forecasts", candidate }). Instance names must be
unique in the runtime, including nested loaders. A child registration inherits
its entry’s identity.
Loader edits to a managed tree fail with ManagedFork before writing the file
or changing its accepted target. Hand-written file changes cannot be prevented;
watch refresh rejects them and retains the previous target. A transition changes
the live generation only; it does not rewrite graft.yml. Keep persisted
deployment intent separate from transition outcomes.