@usenaive-sdk/iac is the build-time, declarative half of the SDK. You author a
naive.config.ts, and naive up sets it up. The split is declaration vs execution:
defineProject(...) runs when the config file is evaluated — it validates and refuses,
and makes no API call — and naive up is the execution step that plans and applies what
the config declares. A refusal from defineProject therefore surfaces anywhere the file
is evaluated (naive up, naive up --plan, or your own build), before anything is
applied.
The entrypoint is defineProject — strict: declaring a field nothing consumes is a
named refusal at define time (declared_unconsumed: <path> …) instead of a green apply
that enforces nothing. defineConfig remains as a permanent @deprecated lenient
alias with the behavior every existing config was written against; naive up prints
what such a config declared that nothing reads. Nothing needs migrating — switching is
never urgent, only strictly better — but new configs should use defineProject. See
Strict mode for the exact list and a real
refusal, and the
migration guide for moving an
existing config over.
naive up issues no per-tenant resources — no KYB, no card issuing, no carrier call
until forUser(id).provision(role). It is not side-effect free, though, and two of its
effects are company-scoped and durable:
company.brainsis provisioned. Each declared brain becomes a knowledge base, and the declared default is promoted.naive upreports each one as created or converged. These survivenaive down— a brain is the company’s accumulated knowledge, and tearing down a deployment does not mean discarding it.- declared
teams:are materialized and placed.naive upstamps out each team’s agents and reports per-teamplacement(placed / converged / refused) and per-agentprovision_status. A refused team makesnaive upexit non-zero.
result.teams[].placement and result.brains rather than inferring either from the
exit code alone.
The shape
A config has two halves:infrastructure— set up once for the whole project:cloud(web, database, storage) andbusiness(the real-world programs agents draw from).agents— a blueprint per role.naive.forUser(id).provision(role)stamps out a fresh, isolated, governed copy per user.
company: { brain | brains, residency, governance } and
teams: { … } declare the durable-runtime surface (see Teams).
Declaring either turns on define-time validation for those blocks only. Write
brain: brain({ … }) for one, brains: brains({ default, declared }) for several —
writing both is company_brain_and_brains at define time (the brain
has the whole shape). runtime.pool() (below) is the legacy runtime; on the team surface
it is runtime.hermes() — pool is optional: omitted means the company’s one
hosted-hermes container, and a given name must match a pool declared under the config’s
top-level runtime: block (an unknown name is hermes_pool_unknown at define time). The
current runtime is runtime.durable({ … }) — which is also what a team with no runtime:
line gets, so choosing hermes is always an explicit act. One member can be pinned on its own with agent({ runsOn }); see
Choosing a runtime. Every existing config
keeps compiling.
More than one project
project: is the project this config applies to — the same
layer naive projects manages, and a plain string is one project. An organization that
needs several declares them under projects:, and naive up plans and applies each in
turn:
projects: entirely and nothing changes — the single-string form
is exactly what it always was.
Strict mode — what defineProject refuses
defineProject is defineConfig plus one pass: every field on the accept-and-drop
list that the config declares is refused at define time. The list is the set of fields
the DSL accepts but nothing yet consumes — declared, dropped (or carried and read by
nothing), and enforced nowhere. Under the lenient defineConfig they apply green and
naive up warns; under defineProject they refuse before anything is applied:
agent().time, agent().secrets,
agent().skills, team().time, runtime.durable({ workspace }), company.residency,
and the top-level modules: block. Wiring a field deletes its row from the one shared
list, which removes the refusal in the same edit — strictness only shrinks as consumers
land.
Separate from strictness, fields that ARE consumed — but only by one runtime — are not
refused on the other: a hermes-led team declaring agent({ model }) or
team({ review }) applies, and the plan/apply reports each such field in the team’s
unconsumed list (e.g. review (runtime.hermes)). See
Model, rubric and spend.
Builders
The agent block
How it’s enforced (not just declared)
When youprovision(role), the blueprint is translated into a dedicated
AccountKit for that agent and the agent is bound to it:
can→ an allowlist: only those skills are enabled; the governance gateway refuses anything else with403.limits.approve→ escalate-only human-in-the-loop gates on the listed skills (a matching action parks for approval; a non-match never removes a built-in default).limits.budget→ a real combined cost ceiling at the gateway. It caps BOTH real-world spend (card limits, top-ups, trading notional) AND platform usage (LLM, search, compute, hosted runtime), summed for the window. A hard cap returns403 budget_exceeded(race-safe — reserved under a lock before execution); a soft cap routes to human approval; the alert threshold emits abudget.alertevent. A multi-agentsystemshares one cap across all its agents. If a hard cap (or the company’s credit balance) is exhausted, the agent’s hosted runtime is auto-stopped so you stop incurring cost.
can omits skills.trading returns 403 on
forUser(id).trading.*, and a budget of $1 (hard) makes a $250 card return
403 budget_exceeded — both verified end-to-end.
limits.budget binds on both transports; can and limits.approve do not, quite.
The combined ceiling is enforced inside the agent-profile context, which is bound around
every HTTP request and around every MCP tool call, so a capped agent is capped whichever
way it calls. The capability allow-list and the approval requirement are decided by the
governor, and on MCP the governor is consulted by 28 of the 271 tools — the money and
identity verbs. A can rule about, say, search or image generation is enforced on the
HTTP route and not on the equivalent MCP tool. Detail:
the governance gateway.For fine-grained control you can still drop to the lower-level
agentTemplate({ identity, wallet, comms, policy }) + policy({ allow, deny, approvals, autoApprove, network }) builders under agentProfiles — the
agent({...}) DSL compiles down to exactly that.Account kits in the config
kits: is the canonical spelling of “declare now, instantiate later”. A kit is
a role your config registers once; nothing runs until your own code stamps an
instance of it for one child project — the same
account-kit rows the SDK already manages.
kit() takes the same fields as agent() (including runsOn — a kit may pin
the runtime its instances run on), and compiles to byte-for-byte the same
canonical output.
Relationship to agents: — the top-level agents: block has always meant
exactly this, so it stays as a deprecated alias with identical behavior: both
spellings compile to the same output, and naive up prints a rename notice for
agents:. Declaring the same name in both blocks is refused at define time
(kit_and_agent_duplicate). Rename the block to kits:; nothing else changes.
Multi-agent systems
Asystem(...) composes a parent/manager agent (which holds the shared budget)
with sub-agents, each in its own isolated runtime.
Constraint: root must be the agent role named "ceo". The hosted runtime
runs a single Hermes gateway pinned to the ceo profile, and dashboard /
naive ceo run dispatch through it. A root under any other name would be
created and governed but never receive CEO work — naive up rejects that with
invalid_input. Give the CEO whatever title/skills you want; only the role
name is fixed.
revoke(parent) kills
the whole system.
How naive up sets up infrastructure
naive up is a plan → apply lifecycle backed by a Naïve-managed executor
(no IaC toolchain for you to operate). Each resource maps to a managed
backend, which Naïve provisions and keeps reconciled for you:
cloud.bucket→ a private object-storage bucket.cloud.postgres→ a managed Postgres database (created async; the pooledDATABASE_URLis returned as an output once healthy).cloud.web→ web hosting.naive upuploads the resource’sdirand triggers a production deployment; env is auto-injected from sibling outputs (the PostgresDATABASE_URL, the bucket name).business.*→ the shared real-world programs (entity/email/phone/cards); each agent draws its own instance from them atprovision()time.
202 { job_id } — and the CLI
polls GET /v1/jobs/:id until the job completes, then prints the same apply
report a synchronous run would. Only one apply runs per company at a time; a
second naive up while one is in flight answers 409 duplicate_request with
the running job to poll. (Direct API callers: send Prefer: respond-async, or
?async=true, to opt in; a plain POST /v1/deployments/:project/apply stays
synchronous.)
What naive up prints beside the plan
Three notices ride on the plan and the apply output, so a lenient config still hears
about everything strict mode would have refused:
- Per-team
unconsumedlists — one warning line per team that declared fields nothing on its runtime reads:⚠ team "support" DECLARED AND UNREAD: …. Do not treat those fields as in force. - A legacy-surface deprecation notice when the config still uses
runtime.pool()declarations, agent-levelruntime: "<pool>"strings, orsystems:. All three keep working indefinitely; new configs authordefineProjectwithteams:onruntime.durable(). - A rename notice when the config declares
agents:— a deprecated spelling, not a deprecated surface: the block compiles to exactly whatkits:compiles to.