Extensions
Extensions teach the CLI new capabilities. Each extension provides commands — build, test, lint, serve, publish — for a specific language, framework, or tool. The extension model is open: you can write extensions in any language and distribute them as workspace packages, npm packages, or standalone directories.
Official extensions
Putnami ships three language extensions:
| Extension | Purpose | Commands | Details |
|---|---|---|---|
@putnami/typescript |
TypeScript/JavaScript toolchain | build, test, lint, serve, run, package, generate | TypeScript extension |
@putnami/go |
Go toolchain | build, test, lint, serve, run, package | Go extension |
@putnami/python |
Python toolchain | test, lint, serve | Python extension |
Each language extension page documents its commands, flags, caching behavior, and templates in full.
Beside them, two cross-language extensions serve every ecosystem:
| Extension | Purpose | Commands | Activates on |
|---|---|---|---|
@putnami/scaffold |
Packages a project template into a distributable archive | package | putnami.template.json |
@putnami/clientgen |
Generates typed REST clients for a service's consumers from its OpenAPI document | clientgen | A provider project |
Neither is tied to one language: a template is packaged the same way whatever it renders into, and a client is generated from the provider's published contract rather than from either side's source. See Templates for scaffolding.
Installing extensions
putnami deps add @putnami/typescript @putnami/go @putnami/pythonExtensions are auto-discovered from:
- Workspace-level entries in
putnami.workspace.json→extensionsfield - Scope-level entries in scope
putnami.json→extensionsfield - Project-level
devDependenciesinpackage.json
No manual registration is needed for npm-based extensions.
Extension caching
Discovered extensions are cached in .putnami/extensions.cache.json for fast startup. The cache auto-invalidates when dependencies change.
The extension manifest
Every extension is defined by a putnami.extension.json file. The manifest declares commands (the public API) and tasks (atomic executable units).
Minimal manifest
{
"$schema": "https://putnami.dev/schemas/putnami-extension.json",
"commands": {
"build": {
"run": [{ "id": "build", "task": "build-exec" }]
}
},
"tasks": {
"build-exec": {
"kind": "command",
"command": "echo",
"args": ["built"]
}
}
}Top-level fields
| Field | Required | Description |
|---|---|---|
$schema |
no | JSON Schema reference for editor support |
commands |
yes | Public commands exposed to users |
tasks |
no | Atomic executable units referenced by commands |
hooks |
no | Lifecycle hooks (e.g., preBuild) |
templates |
no | Project templates (see Templates) |
contracts |
no | Input/output schemas for task validation |
extensionDependencies |
no | Other extensions this one depends on |
workspaceDevDependencies |
no | NPM devDependencies required at workspace root |
autoServe |
no | Whether serve auto-discovers projects with this extension |
runtime |
no | The extension's own executable and how to prepare it (see Lifecycle primitives) |
workspace |
no | Project discovery and metadata this extension owns (see Lifecycle primitives) |
Commands
A command is a user-facing operation invoked via putnami <command>. It defines activation rules, CLI flags, and a pipeline of steps:
{
"commands": {
"build": {
"description": "Build the project",
"activationFiles": ["tsconfig.json"],
"flags": {
"target": {
"type": "string",
"default": "production",
"choices": ["development", "production"]
}
},
"run": [
{ "id": "generate", "task": "codegen", "dependsOn": ["^generate"] },
{ "id": "transpile", "task": "build-transpile", "dependsOn": ["generate"] },
{ "id": "types", "task": "build-types", "dependsOn": ["generate"] }
]
}
}
}Command fields:
| Field | Required | Description |
|---|---|---|
run |
yes | Pipeline step DAG |
description |
no | Human-readable description |
activationFiles |
no | File patterns that must exist for the command to activate |
flags |
no | CLI flags the command accepts |
defaults |
no | Default parameter values |
priority |
no | Higher priority wins when multiple extensions provide the same command |
quiet |
no | Suppress from job recap |
visibility |
no | public (default) or internal |
outputs |
no | Named outputs projected from step results |
channel |
no | Execution channel for concurrency control |
Commands only activate for projects that match activationFiles patterns. If no patterns are specified, the command activates for all projects that use the extension.
Flag properties:
| Property | Type | Description |
|---|---|---|
type |
"string" | "boolean" | "number" | "array" |
Value type |
short |
string? |
Short alias (e.g., "-r") |
description |
string? |
Help text |
default |
any? |
Default value |
required |
boolean? |
Whether the flag must be provided |
choices |
string[]? |
Valid values for string or array types |
Tasks
A task is an atomic executable unit — a subprocess with defined inputs, outputs, and caching:
{
"tasks": {
"build-transpile": {
"kind": "command",
"command": "bun",
"args": ["run", "{extensionRoot}/bin/build"],
"cwd": "{projectRoot}",
"timeoutMs": 300000,
"inputs": {
"source": { "from": "project", "files": ["src/**/*.ts", "src/**/*.tsx"] },
"config": { "from": "project", "files": ["tsconfig.json", "package.json"] }
},
"outputs": {
"dist": { "kind": "directory", "path": "dist/" }
},
"cache": {
"enabled": true,
"deterministic": true
}
}
}
}Task fields:
| Field | Required | Description |
|---|---|---|
kind |
yes | Execution kind (currently only command) |
command |
yes | Command to execute |
args |
no | Command arguments |
cwd |
no | Working directory (supports template variables) |
env |
no | Environment variables |
timeoutMs |
no | Execution timeout (default: 300000ms) |
inputs |
no | Input port declarations |
outputs |
no | Output port declarations |
writes |
no | Resources written; conflicting accesses are serialized (project- or workspace-scoped) |
reads |
no | Resources read; serialized against conflicting writers |
cache |
no | Cache policy ({ enabled, deterministic }) |
inputSchemaRef |
no | Reference to a contract schema for input validation |
outputSchemaRef |
no | Reference to a contract schema for output validation |
Template variables in task fields:
| Variable | Expands to |
|---|---|
{extensionRoot} |
Absolute path to the extension directory |
{projectRoot} |
Absolute path to the project directory |
{workspaceRoot} |
Absolute path to the workspace root |
{selectedProjects} |
Comma-separated selected project names for workspace-once jobs |
{selectedProjectIDs} |
Comma-separated selected project IDs for workspace-once jobs |
{selectedProjectPaths} |
Comma-separated selected project paths for workspace-once jobs |
{selectedProjectRoots} |
Comma-separated selected project roots for workspace-once jobs |
Input ports
Tasks declare their inputs via inputs. Each input has a source:
| Source | Description |
|---|---|
project |
Files from the project directory (use files for globs) |
task |
Output from another task in the pipeline |
params |
Command parameters |
env |
Environment variables |
runtime |
Runtime context (platform, arch, versions) |
Pipeline steps
Each step in a command's run array references a task and declares its position in the execution DAG:
| Field | Required | Description |
|---|---|---|
id |
yes | Unique step identifier |
task |
yes | Name of a task in the extension's tasks section |
dependsOn |
no | Step IDs or external references this step depends on |
with |
no | Input bindings passed to the task |
optional |
no | If true, failure marks step as skipped (not failed) |
if |
no | Plan-time condition over params, effective commands and their resolved params, and project type — excludes step from DAG |
runOn |
no | Lifecycle condition: success (default), or finally with an explicit finalizes relation |
finalizes |
with runOn: finally |
Invocation-resource producer and complete downstream consumer frontier |
cache |
no | Per-step cache override |
timeoutMs |
no | Per-step timeout override |
Dependency references in dependsOn:
| Pattern | Meaning |
|---|---|
"transpile" |
Wait for step transpile in the same command |
"^generate" |
Wait for each upstream project's generate step of the same command |
"/workspace-install" |
Wait for the workspace-once command's workspace-install step |
"*generate" |
Wait for every other job that is the generate step, or belongs to the generate command |
The ^ prefix is the most common — it creates cross-project dependency chains so downstream projects wait for their dependencies to complete the same phase.
References resolve by exact identity: the step id a job is, within the
command the reference is written in (* additionally matches a command name).
Display names, internal plan names (build~generate) and synthesized
project:name keys are not addressable — a reference that only a name-based
match would have resolved is a plan-time error naming what it would have bound.
Command-level dependsOn also supports release gates. Bare command names are
same-project prerequisites; !command is a session barrier across the current
project selection:
{
"publish": { "dependsOn": ["!lint", "!test", "!build", "!package"] },
"deploy": { "dependsOn": ["!publish"] }
}!command dependencies are functional: if a barrier job fails, downstream
side-effecting jobs are skipped unless --continue-on-error is set.
Conditional execution:
if(plan-time): evaluated before the pipeline runs, removes the step entirely from the graphrunOn(execution):success(default) runs only after successful dependencies;finallymarks the containing step as the finalizer and requiresfinalizes.producerplus the complete downstreamfinalizes.consumersfrontier
{ "id": "types", "task": "build-types", "if": "params.emitTypes == true" }
{
"id": "teardown",
"task": "database-down",
"runOn": "finally",
"finalizes": { "producer": "setup", "consumers": ["test"] }
}Producer start arms the finalizer exactly once; cleanup waits until every listed
consumer is terminal. The producer task must declare an invocation-scoped
output, and the finalizer has no ordinary dependsOn or result exports.
Input bindings via with:
{
"id": "transpile",
"task": "build-transpile",
"with": {
"target": { "value": "production" },
"config": { "from": "command", "path": "flags.target" },
"schemas": { "fromStep": "generate", "output": "schemas" }
}
}The JSONL event protocol
Extensions communicate with the CLI via JSON Lines on stdout. Each line is a JSON object with protocol version v: 1 and a type field. Stderr is reserved for human-readable diagnostic output.
Invocation
The CLI spawns task commands as:
{command} [args...] --putnamiContext <file> --output jsonl [job-flags...]The --putnamiContext argument points to a temporary JSON file with workspace, project, extension, and parameter context:
{
"workspaceRoot": "/home/user/my-workspace",
"workspace": { "name": "my-workspace", "rootPath": "/home/user/my-workspace" },
"project": {
"name": "@myorg/my-app",
"path": "apps/my-app",
"fullPath": "/home/user/my-workspace/apps/my-app"
},
"extension": { "name": "my-extension", "path": "./extensions/my-extension" },
"job": { "name": "build" },
"params": { "target": "linux/amd64" },
"outputPath": "/tmp/putnami/my-extension/my-app/build",
"cacheRoot": "/tmp/putnami/my-extension/my-app"
}The project field is absent for workspace-level jobs.
Event types
| Event | Description | Key fields |
|---|---|---|
meta |
Job metadata (emit once at start) | data: { extension, job } |
log |
Structured log message | level, message |
phase |
Lifecycle phase markers | name, action (start/end), status on end |
progress |
Progress reporting | current, total, message |
diagnostic |
Errors/warnings with source location | severity, message, code, location: { file, line, column } |
metric |
Performance and size metrics | name, value, unit |
artifact |
Output artifact registration | id, name, kind, path |
summary |
Human-readable label for job recap | message |
result |
Job result (must be last event) | data: { status: "OK" | "FAILED" | "SKIP" } |
Example JSONL output
{"v": 1, "type": "meta", "time": "...", "level": "info", "message": "Starting build", "data": {"extension": "my-ext", "job": "build"}}
{"v": 1, "type": "phase", "time": "...", "name": "compile", "action": "start"}
{"v": 1, "type": "log", "time": "...", "level": "info", "message": "Compiling 42 files..."}
{"v": 1, "type": "progress", "time": "...", "current": 21, "total": 42, "message": "Compiling..."}
{"v": 1, "type": "diagnostic", "time": "...", "severity": "warning", "message": "unused import", "code": "TS2304", "location": {"file": "src/main.go", "line": 5, "column": 1}}
{"v": 1, "type": "metric", "time": "...", "name": "binary-size", "value": 4096, "unit": "bytes"}
{"v": 1, "type": "artifact", "time": "...", "id": "binary", "name": "App Binary", "kind": "file", "path": "bin/app"}
{"v": 1, "type": "summary", "time": "...", "message": "Build complete (4.1 KB)"}
{"v": 1, "type": "phase", "time": "...", "name": "compile", "action": "end", "status": "success"}
{"v": 1, "type": "result", "time": "...", "level": "info", "message": "Job OK", "data": {"status": "OK"}}Lifecycle primitives
Beyond commands and tasks, an extension can take ownership of three generic lifecycle surfaces. There is no fourth: anything that does not fit one of them stays an ordinary typed task.
Runtime preparation
Declare the executable your tasks run on, and how to build it, instead of the CLI knowing which wrapper binary your ecosystem ships:
{
"runtime": {
"executable": "bin/putnami-ts",
"prepare": {
"command": "{extensionRoot}/bin/prepare",
"args": ["--output", "{runtimeOutput}"],
"inputs": ["cmd/**", "internal/**", "go.mod", "go.sum"]
}
}
}Tasks then reference it as {extensionRuntime}. Preparation runs once per run,
before any job is scheduled; it resolves dependencies from the extension's own
module alone, so a locally prepared runtime and one shipped inside a published
archive are the same artifact. The prepared artifact's digest is part of every
cache key for tasks that run on it. prepare is optional — omit it when each
platform archive already carries the binary.
Workspace adapter
Declare which directories are your projects, and what makes their metadata stale:
{
"workspace": {
"markers": ["package.json"],
"inputs": ["package.json", "bun.lock", "tsconfig*.json"],
"excludes": ["node_modules", "dist"],
"syncTask": "workspace-sync"
}
}The CLI asks your extension — it never parses a language manifest itself. Every
marker must also appear in inputs, because the content digest of inputs is
what decides whether a recorded answer is still valid. Answers are cached in
.putnami/workspace-index.json, so an unchanged workspace starts zero probe
processes; only the providers whose own declared inputs moved are re-asked.
Provider-specific metadata is namespaced under your extension's name, and
syncTask is where you make your own manifest edits (putnami projects sync
runs it). An extension that declares no workspace block is never probed.
Machine caches
An extension that keeps a machine-global cache owns it end to end. The CLI hands
it one stable directory (extension.cacheRoot in the job context) and never
looks inside. Declare the two reserved internal commands to participate in the
cache lifecycle:
| Command | Invoked by |
|---|---|
cache-clean |
putnami cache clean — a cold reset |
cache-gc |
putnami cache gc, and the CLI's throttled opportunistic collection |
Both are ordinary commands running ordinary tasks, so cache policy is declared and validated like the rest of your manifest.
Invocation-scoped resources and cleanup
Use this for anything that exists only for the duration of one CLI invocation — a throwaway database, a temporary credential, a socket:
{
"tasks": {
"test-database-setup": {
"kind": "command",
"command": "{extensionRuntime}",
"args": ["database", "up", "--dsn-file", "{invocationArtifactRoot}/database/dsn.env"],
"cache": false,
"declares": {
"outputs": {
"dsn": { "kind": "runtime-file", "scope": "invocation", "sensitive": true, "path": "database/dsn.env" }
}
}
}
},
"commands": {
"test": {
"run": [
{ "id": "setup", "task": "test-database-setup" },
{ "id": "run", "task": "test-run", "dependsOn": ["setup"] },
{ "id": "teardown", "task": "test-database-teardown", "runOn": "finally",
"finalizes": { "producer": "setup", "consumers": ["run"] } }
]
}
}
}scope: "invocation"keeps the output in the invocation's private scratch: it is never captured into the cache and never restored from it.sensitive: trueguarantees its path and bytes never reach an event, a result, a session record, telemetry or cache traffic.runOn: "finally"plusfinalizesmakes a step the finalizer. The producer's start arms it exactly once, and it runs after every named consumer reaches a terminal state — including on failure and on Ctrl-C.- A task with an invocation-scoped output must set
cache: false: a cache hit would report success without ever creating the resource.
Crash recovery. A SIGKILL cannot run a finalizer, so cleanup is
contractual. The CLI holds a non-secret lease (invocation ID, PID, provider,
action digest, creation time); stamp the matching non-secret identity onto any
external resource you create, and reap resources whose lease has no live owner
before provisioning or reusing anything. The next invocation after a kill is
then the one that recovers the orphan, with no GC interval to wait for.
Credentials never belong in a lease, label, name, filter, log or error.
The full contract — every field, merge rule, lifecycle state and failure code —
is in protocols/extension/doc/07-lifecycles.md.
Any-language extensions
Extensions can be written in any language — Go, Python, shell scripts, Rust — without requiring npm, TypeScript, or Bun. The only requirements are a putnami.extension.json manifest and executables that speak the JSONL protocol on stdout.
For an extension written in Go, the extension SDK (putnami-extension-sdk,
Go module go.putnami.dev/sdk/extension) supplies the orchestrator-facing half
so the extension only has to supply what its own ecosystem knows: the JSONL
emitter and structured log forwarding, the job context and flag parsing, the
manifest builder that runs the same validation the CLI runs at build time,
subprocess execution, and the neutral half of every shared lifecycle primitive —
machine-cache eviction, infra aggregation, database test environments,
checkout-independent generation paths, and the single writer of the
package→publish metadata index. Extensions in other languages implement the
manifest and the JSONL protocol directly, as the shell example below does.
my-extension/
├── putnami.extension.json # Manifest with commands and tasks
└── bin/
├── build # Executable (any language)
├── test
└── lintExample: Shell script extension
putnami.extension.json:
{
"$schema": "https://putnami.dev/schemas/putnami-extension.json",
"commands": {
"build": {
"flags": {
"target": { "type": "string", "default": "release", "choices": ["debug", "release"] },
"clean": { "type": "boolean", "default": false, "description": "Clean before building" }
},
"run": [{ "id": "build", "task": "build-exec" }]
}
},
"tasks": {
"build-exec": {
"kind": "command",
"command": "{extensionRoot}/bin/build",
"cwd": "{projectRoot}",
"inputs": {
"source": { "from": "project", "files": ["src/**/*", "Makefile"] }
},
"cache": { "enabled": true }
}
}
}bin/build:
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/putnami-jsonl.sh"
CONTEXT_FILE="" TARGET="release" CLEAN="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--putnamiContext) CONTEXT_FILE="$2"; shift 2 ;;
--output) shift 2 ;;
--target) TARGET="$2"; shift 2 ;;
--clean) CLEAN="true"; shift ;;
--no-clean) CLEAN="false"; shift ;;
*) shift ;;
esac
done
[ -z "$CONTEXT_FILE" ] && { echo "Error: --putnamiContext required" >&2; exit 2; }
parse_context "$CONTEXT_FILE"
emit_meta "$PUTNAMI_CTX_EXTENSION_NAME" "$PUTNAMI_CTX_JOB_NAME"
emit_phase_start "build"
emit_log "info" "Building $PUTNAMI_CTX_PROJECT_NAME (target=$TARGET)..."
if make -C "$PUTNAMI_CTX_PROJECT_PATH" "$TARGET" 2>/dev/stderr; then
emit_phase_end "build" "success"
emit_result "OK"
exit 0
else
emit_phase_end "build" "failed"
emit_result "FAILED"
exit 1
fiExample: Python extension
putnami.extension.json:
{
"$schema": "https://putnami.dev/schemas/putnami-extension.json",
"commands": {
"test": {
"run": [{ "id": "test", "task": "test-exec" }]
}
},
"tasks": {
"test-exec": {
"kind": "command",
"command": "{extensionRoot}/bin/test",
"cwd": "{projectRoot}",
"inputs": {
"source": { "from": "project", "files": ["**/*.py"] }
},
"cache": { "enabled": true }
}
}
}bin/test:
#!/usr/bin/env python3
import argparse, json, subprocess, sys
from datetime import datetime, timezone
def emit(event):
event.setdefault("v", 1)
event.setdefault("time", datetime.now(timezone.utc).isoformat())
print(json.dumps(event), flush=True)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--putnamiContext", required=True)
parser.add_argument("--output", default="jsonl")
parser.add_argument("--verbose", "-V", action="store_true")
args = parser.parse_args()
with open(args.putnamiContext) as f:
ctx = json.load(f)
project = ctx.get("project")
if not project:
emit({"type": "result", "data": {"status": "SKIP"}})
sys.exit(0)
emit({"type": "meta", "level": "info", "message": "Starting test",
"data": {"extension": ctx["extension"]["name"], "job": "test"}})
emit({"type": "phase", "name": "test", "action": "start"})
result = subprocess.run(
["python", "-m", "pytest", "-v" if args.verbose else "-q"],
cwd=project["fullPath"], capture_output=True, text=True,
)
if result.returncode == 0:
emit({"type": "phase", "name": "test", "action": "end", "status": "success"})
emit({"type": "result", "level": "info", "message": "Job OK", "data": {"status": "OK"}})
else:
for line in result.stdout.splitlines():
if "FAILED" in line:
emit({"type": "diagnostic", "severity": "error", "message": line.strip()})
emit({"type": "phase", "name": "test", "action": "end", "status": "failed"})
emit({"type": "result", "level": "info", "message": "Job FAILED", "data": {"status": "FAILED"}})
sys.exit(1)
if __name__ == "__main__":
main()Lifecycle hooks
Extensions can define hooks for cross-cutting concerns like code generation.
Command hooks
Command hooks execute as subprocesses:
{
"hooks": {
"preBuild": {
"kind": "command",
"command": "bunx",
"args": ["@my/extension:generate"],
"cwd": "{projectRoot}",
"timeoutMs": 120000
}
}
}When several extensions declare the same hook for one project they share that
project's generated tree, so the order matters. Hooks run in ascending
order (default 0), ties broken by extension name. Leave order unset for a
hook that only writes generated sources, and raise it for a hook that must
observe what the others generated — for example one that imports the workload
entry point.
Module hooks
Module hooks import JavaScript modules directly:
{
"hooks": {
"onLoad": "./hooks/on-load",
"onBeforeJob": "./hooks/on-before-job",
"onAfterJob": "./hooks/on-after-job"
}
}Registering local extensions
Add extension paths to putnami.workspace.json:
{
"extensions": [
"./my-extension",
"/opt/shared-extensions/custom-ext"
]
}Paths can be relative (resolved from workspace root) or absolute. Each path must contain a putnami.extension.json.
Command priority
When multiple extensions provide the same command name, priority determines which one runs:
{
"commands": {
"build": {
"priority": 10,
"run": [{ "id": "build", "task": "build-exec" }]
}
}
}Higher priority value wins (default: 0). Only the highest-priority matching command runs for each project.
Managing extensions
putnami extensions list # List all active extensions
putnami extensions install # Install extensions from workspace config
putnami extensions update # Update to latest compatible versions
putnami extensions remove <ext> # Remove an installed extension
putnami extensions validate [path] # Validate an extension manifest
putnami extensions test [path] # Run extension tests
putnami extensions package [path] # Package for distributionTo materialize artifacts for a platform other than the invoking machine's — pre-warming an image layer, for example — name the target and a destination:
putnami extensions install --platform linux/amd64 --dest ./.gen/warm-artifacts--dest receives a drop-in artifact-store root (<dir>/sha256/<xx>/<digest>/) that can be copied into ~/.putnami/artifacts on the target machine. A materialization skips install hooks, leaves the stable extension links and putnami.lock.json untouched, fails closed when the lock has no integrity for the requested platform, and produces a byte-reproducible tree.
Validating extensions
putnami extensions validate checks:
- JSON Schema conformance
- All pipeline steps reference defined tasks
- Pipeline DAG is acyclic
- Activation file patterns are valid globs
- Contract schema references are valid
putnami extensions validate ./my-extension