Putnami
DocsGitHub

Licensed under FSL-1.1-MIT

Getting Started
Concepts
How To
Build A Web App
Build An Api Service
Share Code Between Projects
Configure Your App
Add Persistence
Add Authentication
Add Background Jobs
Develop With Ai
Structure Business Logic With Di
Upgrade Putnami
Principles
Tooling & Workspace
Workspace
Cli
Jobs & Caching
Extensions
Templates
Error Handling
Frameworks
Typescript
ExtensionOverviewWebReact RoutingForms And ActionsStatic FilesApiErrors And ResponsesConfigurationLoggingHttp And MiddlewareDependency InjectionPlugins And LifecycleSessionsAuthPersistenceEventsStorageCachingWebsocketsTestingHealth ChecksTelemetryProto GrpcSmart ClientSchema
Go
ExtensionOverviewHttpDependency InjectionPlugins And LifecycleConfigurationSecurityPersistenceErrorsEventsStorageCachingLoggingTelemetryGrpcService ClientsValidationOpenapiTesting
Python
Extension
Platform
Ci
  1. DocsSeparator
  2. Tooling & WorkspaceSeparator
  3. Extensions

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 four official extensions:

Extension Purpose Commands Details
@putnami/typescript TypeScript/JavaScript toolchain build, test, lint, serve, generate TypeScript extension
@putnami/go Go toolchain build, test, lint, serve Go extension
@putnami/python Python toolchain test, lint, serve Python extension
@putnami/ci CI/CD automation publish, release CI extension

Each language extension page documents its commands, flags, caching behavior, and templates in full.

Installing extensions

putnami deps add @putnami/typescript @putnami/go @putnami/python

Extensions are auto-discovered from:

  1. Workspace-level entries in putnami.workspace.json → extensions field
  2. Scope-level entries in scope putnami.json → extensions field
  3. Project-level devDependencies in package.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

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
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

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 (params only) — excludes step from DAG
when no Runtime condition (params + step results) — skips step
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 the upstream dependency's generate step
"/project-name:build" Wait for another project's build command
"*generate" Wait for all providers of a generate step

The ^ prefix is the most common — it creates cross-project dependency chains so downstream projects wait for their dependencies to complete the same phase.

Conditional execution:

  • if (plan-time): evaluated before the pipeline runs, removes the step entirely from the graph
  • when (runtime): evaluated during execution, skips the step if false but keeps it in the DAG
{ "id": "types", "task": "build-types", "if": "params.emitTypes == true" }
{ "id": "deploy", "task": "deploy-exec", "when": "steps.test.status == 'success'" }

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"}}

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.

my-extension/
├── putnami.extension.json    # Manifest with commands and tasks
└── bin/
    ├── build                 # Executable (any language)
    ├── test
    └── lint

Example: 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
fi

Example: 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
    }
  }
}

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 distribution

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

On this page

  • Extensions
  • Official extensions
  • Installing extensions
  • Extension caching
  • The extension manifest
  • Minimal manifest
  • Top-level fields
  • Commands
  • Tasks
  • Input ports
  • Pipeline steps
  • The JSONL event protocol
  • Invocation
  • Event types
  • Example JSONL output
  • Any-language extensions
  • Example: Shell script extension
  • Example: Python extension
  • Lifecycle hooks
  • Command hooks
  • Module hooks
  • Registering local extensions
  • Command priority
  • Managing extensions
  • Validating extensions