Extensions
Extensions are packages that provide job implementations and capabilities to your Putnami workspace. They are the mechanism by which Putnami supports different languages, frameworks, and tooling integration.
Extensions are auto-discovered from your workspace dependencies and workspace packages. Any package with a putnami.extension.json file is automatically registered as an extension.
Install extensions
Install extensions at the workspace root:
bun add @putnami/typescript @putnami/go @putnami/python @putnami/gitExtensions are discovered automatically from:
- Local paths listed in
.putnamirc.jsonextensionsfield (for local-path extensions) dependenciesin the rootpackage.jsondevDependenciesin the rootpackage.jsonworkspacesentries (for local development)
npm-based extensions require no manual configuration. Extensions in any language are registered via .putnamirc.json — see Any-Language Extensions.
Extension caching
To optimize startup time, discovered extensions are cached in .putnami/extensions.cache.json. The cache is automatically invalidated when dependencies change (for example after putnami deps install, putnami deps add, or putnami deps remove).
You can manually reset the cache with:
bunx putnami extensions resetDisabling extensions
To disable specific extensions, add them to disable.extensions in the putnami field of the root package.json:
{
"name": "my-workspace",
"putnami": {
"disable": {
"extensions": ["@putnami/go"]
}
}
}Disabling specific jobs
If you only need to disable specific jobs (rather than an entire extension), use disable.jobs in the putnami field of the root package.json:
{
"name": "my-workspace",
"putnami": {
"disable": {
"jobs": ["@putnami/typescript:lint"]
}
}
}Or disable jobs per-project in the project's package.json:
{
"name": "@myorg/my-app",
"putnami": {
"disable": {
"jobs": ["lint"]
}
}
}Unqualified names (e.g., "lint") disable all jobs with that name. Qualified names (e.g., "@putnami/typescript:lint") disable only the specific extension's job.
See Jobs & Commands for details.
Extension dependencies
Extensions can declare dependencies on other extensions with optional version constraints:
{
"extensionDependencies": ["@putnami/typescript"]
}Or with version constraints:
{
"extensionDependencies": {
"@putnami/typescript": "^2.0.0",
"@putnami/python": ">=1.5.0"
}
}Supported version patterns:
*— Any versionworkspace:*— Any workspace version^1.2.3— Compatible with 1.2.3 (semver caret)~1.2.3— Approximately 1.2.3 (semver tilde)>=1.0.0— Greater than or equal1.2.3— Exact versionDependencies are validated when extensions are loaded
Version mismatches throw a descriptive error
Circular dependencies are detected and throw an error
Missing dependencies throw a descriptive error
Job priority
When multiple extensions provide the same job name, priority determines which one runs:
{
"jobs": {
"build": {
"kind": "command",
"command": "bun",
"args": ["@my/extension:build"],
"priority": 10
}
}
}- Higher priority value wins (default: 0)
- Only the highest-priority matching job runs for each project
Lifecycle hooks
Extensions can define lifecycle hooks for cross-cutting concerns like logging, metrics, or cleanup.
Module hooks (legacy)
Module hooks import JavaScript modules directly:
{
"hooks": {
"onLoad": "./hooks/on-load",
"onBeforeJob": "./hooks/on-before-job",
"onAfterJob": "./hooks/on-after-job"
}
}Available hooks:
onLoad— Called when the extension is first loadedonBeforeJob— Called before each job executiononAfterJob— Called after each job completes (success or failure)
Hook implementation:
// hooks/on-before-job.ts
import type { OnBeforeJobContext } from '@putnami/sdk/extensions';
export const hook = async (ctx: OnBeforeJobContext): Promise<void> => {
console.log(`Starting job ${ctx.jobName}`);
};Hook failures are logged but do not fail the job execution.
Command hooks
Command hooks execute as subprocesses, enabling language-agnostic implementations and better isolation:
{
"hooks": {
"preBuild": {
"kind": "command",
"command": "bunx",
"args": ["@my/extension:generate"],
"cwd": "{projectRoot}",
"timeoutMs": 120000,
"cache": {
"inputs": ["src/**/*.tsx"],
"outputs": [".gen/**/*"]
}
}
}
}Command hook properties:
| Property | Type | Description |
|---|---|---|
kind |
"command" |
Discriminator for command hooks |
command |
string |
Command to execute (e.g., bunx, node, python) |
args |
string[] |
Arguments to pass |
cwd |
string? |
Working directory (supports template variables) |
env |
Record<string, string>? |
Environment variables |
timeoutMs |
number? |
Timeout in milliseconds (default: 120000) |
cache |
object? |
Cache configuration for incremental builds |
Template variables:
{workspaceRoot}— Absolute path to workspace root{projectRoot}— Absolute path to project root{extensionRoot}— Absolute path to extension package{outputRoot}— Output directory (usually.gen){cacheRoot}— Cache directory for the extension
JSONL output protocol:
Command hooks communicate via JSON Lines on stdout. The SDK provides helpers for TypeScript:
#!/usr/bin/env bun
import {
CommandModel,
Flag,
runHookCommand,
emitLog,
emitProgress,
type HookContext,
type HookResult,
type StandardHookOptions,
} from '@putnami/sdk';
@CommandModel({ name: 'generate', description: 'Generate code' })
class GenerateOptions implements StandardHookOptions {
@Flag({ flagName: '--putnami-context', required: true })
putnamiContext = '';
@Flag({ flagName: '--output' })
output: 'text' | 'jsonl' | 'both' = 'jsonl';
@Flag({ flagName: '--help', shorts: ['-h'], required: false })
help = false;
}
async function runGenerate(
_options: GenerateOptions,
context: HookContext
): Promise<HookResult> {
emitLog('info', `Working in ${context.projectRoot}`);
emitProgress('Generating', 50, 'codegen');
// ... generation logic ...
return {
exports: { loader: '/path/to/loader.ts' },
assets: { 'bundle.js': '/path/to/bundle.js' },
};
}
if (import.meta.main) {
runHookCommand({
model: GenerateOptions,
extension: '@my/extension',
hook: 'preBuild',
version: '1.0.0',
run: runGenerate,
});
}Exit codes:
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Build error |
| 2 | Invalid configuration |
| 130 | Interrupted (SIGINT) |
Official Extensions
@putnami/typescript
The primary extension for JavaScript and TypeScript development. It handles building, testing, and linting for JS/TS projects.
Purpose:
- Provides a unified toolchain for TypeScript development.
- Eliminates the need for per-package build configuration files.
Jobs:
- build: Bundles code using a combination of Bun and Esbuild for optimal performance. Supports
browser,node, andserviceworkertargets. - test: Runs tests using the highly performant Bun test runner.
- lint: Orchestrates ESLint and Prettier to ensure code quality and formatting.
Templates: typescript-web, typescript-server, typescript-library
Configuration:
coverage(boolean): Enable test coverage reporting (default:true).fix(boolean): Auto-fix linting issues where possible (default:true).
@putnami/go
Provides comprehensive support for the Go programming language. All job scripts are standalone shell scripts — no Bun or npm required at runtime.
Purpose:
- Abstraction over standard Go commands for workspace compatibility.
- Works as an any-language extension with the compiled CLI binary.
Jobs:
- build: Compiles Go binaries and libraries using
go build. - test: Runs
go testwith race detection and coverage. - lint: Runs
golangci-lintandstaticcheckfor static analysis. - serve: Runs Go applications in development or production mode.
Templates: go-server, go-library
Self-contained: Fully self-contained — Go is auto-downloaded on first use. Only bash, jq, and curl/wget needed. See Go getting started.
@putnami/python
Enables Python support within the workspace, facilitating hybrid applications.
Purpose:
- Integrate Python services or scripts alongside other code.
- Standardize Python tooling execution.
Jobs:
- build: Prepares Python packages.
- test: Runs Python tests (e.g., using
pytest). - lint: Runs Python linters (e.g.,
rufforpylint).
Templates: python-server, python-library
@putnami/git
Provides Git integration for versioning and repository management.
Purpose:
- Automate version tagging and changelog generation.
- Enforce commit message conventions via hooks.
Jobs:
- tag: Creates git tags for released versions.
- versioning: Manages semantic versioning bumping.
Any-Language Extensions
Extensions can be written in any language — Go, Python, shell scripts, Rust, etc. — without requiring npm, TypeScript, or Bun. These extensions declare their CLI flags in the manifest and use any executable as the job command. Combined with the compiled CLI binary, this enables fully npm-free deployments.
Step-by-step tutorial: How to: Create an extension
Structure
my-extension/
├── putnami.extension.json # Manifest with embedded flags
└── bin/
├── build # Executable (any language)
├── test # Executable
└── lint # ExecutableNo package.json, no node_modules, no TypeScript modules required.
Manifest with embedded flags
Declare CLI flags directly in putnami.extension.json:
{
"name": "my-go-extension",
"jobs": {
"build": {
"kind": "command",
"command": "{extensionRoot}/bin/build",
"cwd": "{projectRoot}",
"filePatterns": ["**/*.go", "go.mod"],
"flags": {
"target": { "type": "string", "default": "linux/amd64", "description": "Build target" },
"race": { "type": "boolean", "default": false, "description": "Enable race detector", "short": "-r" },
"tags": { "type": "array", "description": "Build tags" }
}
}
}
}Flag definition properties:
| Property | Type | Required | Description |
|---|---|---|---|
type |
"string" | "boolean" | "number" | "array" |
Yes | Value type. "array" allows repeated --flag v1 --flag v2 |
short |
string |
No | Short alias (e.g., "-r") |
description |
string |
No | Help text |
default |
string | boolean | number | string[] |
No | Default value |
required |
boolean |
No | Whether the flag must be provided |
choices |
string[] |
No | Valid values for "string" or "array" types |
When flags are present in a job definition, the SDK builds CLI commands directly from them instead of importing a TypeScript module.
Template variables
These placeholders are resolved at runtime in command, args, cwd, and env values:
| Variable | Description |
|---|---|
{workspaceRoot} |
Absolute path to the workspace root |
{projectRoot} |
Absolute path to the project being processed |
{extensionRoot} |
Absolute path to the extension directory |
{outputRoot} |
Output directory for job artifacts |
{cacheRoot} |
Cache directory for the extension |
Registering extensions via local paths
Add extension paths to .putnamirc.json at the workspace root:
{
"extensions": [
"./my-extensions/go-ext",
"/opt/shared-extensions/custom-ext"
]
}Paths can be relative (resolved from workspace root) or absolute. Each path must contain a putnami.extension.json manifest.
The extension name is resolved from (in order):
- The
namefield inputnami.extension.json - The
namefield inpackage.json(if present) - The directory name as fallback
JSONL event protocol
Extensions communicate with the Putnami orchestrator via JSONL events on stdout. Each line is a JSON object with protocol version v: 1 and a type field.
Important: stdout is reserved for JSONL events. Use stderr for human-readable diagnostic output.
Invocation
The orchestrator spawns the job command as:
{command} [args...] --putnamiContext <file> --output jsonl [job-flags...]The --putnamiContext argument points to a temporary JSON file containing 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", "race": false },
"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 | Required 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, optionally total and message |
diagnostic |
Errors/warnings with source location | severity, message, optionally location: { file, line } |
metric |
Performance and size metrics | name, value, unit |
artifact |
Output artifact registration | id, name, kind, path |
result |
Job result (must be the 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", "location": {"file": "src/main.go", "line": 5}}
{"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": "phase", "time": "...", "name": "compile", "action": "end", "status": "success"}
{"v": 1, "type": "result", "time": "...", "level": "info", "message": "Job OK", "data": {"status": "OK"}}Exit codes
| Code | Meaning |
|---|---|
0 |
Success (status: "OK" or "SKIP") |
1 |
Build error (status: "FAILED") |
2 |
Invalid config (missing context file, bad parameters) |
130 |
Interrupted (SIGINT) |
Example: Shell script extension
A complete extension using shell scripts. The putnami-jsonl.sh helper library provides emit functions for all event types.
putnami.extension.json:
{
"name": "shell-builder",
"jobs": {
"build": {
"kind": "command",
"command": "{extensionRoot}/bin/build",
"cwd": "{projectRoot}",
"flags": {
"target": { "type": "string", "default": "release", "choices": ["debug", "release"] },
"clean": { "type": "boolean", "default": false, "description": "Clean before building" }
}
}
}
}bin/build:
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/putnami-jsonl.sh"
# Parse arguments.
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; }
# Read context (sets PUTNAMI_CTX_* variables).
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
fiA reusable putnami-jsonl.sh helper library and complete sample extension are available in samples/shell-extension/.
Example: Python extension
bin/test (Python):
#!/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()Creating Custom Extensions
You can extend Putnami's capabilities by creating your own extensions. An extension is simply a package that provides job implementations — either as TypeScript modules (npm-based) or as executables in any language. For non-TypeScript extensions, see the how-to guide for a step-by-step tutorial.
To create an extension:
- Create a new package (workspace package or published npm package).
- Add a
putnami.extension.jsondescribing your commands and jobs. - Export the job implementation files referenced by
putnami.extension.json. - Install the extension at the workspace root.
Minimal putnami.extension.json
{
"extensionDependencies": {
"@putnami/typescript": "^1.0.0"
},
"hooks": {
"preBuild": {
"kind": "command",
"command": "bun",
"args": ["@my/extension:pre-build"],
"cwd": "{projectRoot}"
}
},
"commands": {
"build": "./build"
},
"jobs": {
"build": {
"kind": "command",
"command": "bun",
"args": ["@my/extension:build"],
"cwd": "{projectRoot}",
"filePatterns": ["src/**/*", "package.json"],
"priority": 0
}
}
}Extension properties:
extensionDependencies(optional): Other extensions this extension depends on (array or object with version constraints)hooks(optional): Command hooks for build lifecycle (e.g.,preBuild)commands: CLI command mappings to module pathsjobs: Command-based job definitionsautoServe(optional, default:true): Whether projects with this extension should be auto-discovered as service dependencies duringputnami serve. Set tofalseto prevent infrastructure packages from being auto-started in multi-service sessions. Projects can still be served explicitly or viarunsWith.
Job properties:
kind:"command"— discriminator for command-based jobscommand: Command to execute (e.g.,"bun","node")args: Arguments to pass to the commandcwd(optional): Working directory (supports template variables)dependsOn(optional): Job dependencies using scope syntax (for project-level overrides; in manifests, use pipeline stepdependsOninstead)filePatterns(optional): Files to track for cache invalidationpriority(optional): Priority for job selection (higher wins, default: 0)cache(optional): Whether to cache results (default: false)channel(optional): Publish channel (e.g.,"npm","docker"). When set, the job only runs if the project'sputnami.publishincludes this channel
dependsOn scope syntax
Use prefixes in pipeline step dependsOn to control where a dependency runs. Steps can mix intra-pipeline refs (step ids) with external refs:
"stepId"— intra-pipeline step dependency"^stepId"— individual task on all transitive upstream dependencies (when the ref matches a step in the current pipeline)"^commandName"— full command on upstream dependencies (when the ref does NOT match a step in the current pipeline)"*job"— job on all downstream dependents"/job"— workspace-level job (no project context)
Any external ref can be scoped to a specific extension using @extension/name:job syntax:
"^@putnami/typescript:publish"— only the typescript extension's publish on upstream dependencies
This is useful when multiple extensions provide the same job name and you need to control which one cascades through the dependency graph.
^stepId (task-level upstream deps): When the ^ ref matches a step in the current pipeline, the planner emits only that individual task for each transitive upstream project — not their full pipeline. Dependencies between upstream tasks follow the project dependency graph and the pipeline's intra-step ordering.
{
"commands": {
"publish": {
"run": [
{ "id": "generate", "task": "build-generate", "dependsOn": ["^generate"] },
{ "id": "compile", "task": "build-compile", "dependsOn": ["generate", "^compile"] },
{ "id": "docker", "task": "publish-docker", "dependsOn": ["compile"], "if": "params.docker" }
]
}
}
}Running putnami publish my-app (where my-app depends on sdk which depends on utils):
- utils and sdk get only
generateandcompiletasks - my-app gets the full pipeline (generate, compile, docker)
- No upstream npm/docker/sentinel jobs are scheduled
^commandName (full command fallback): When the ^ ref does NOT match any step in the current pipeline, the planner schedules the full command on upstream projects. This is used for cross-command dependencies.
{
"commands": {
"build": {
"run": [
{ "id": "tidy", "task": "go-tidy" },
{ "id": "compile", "task": "go-compile", "dependsOn": ["tidy", "^build"] }
]
}
}
}Flags can be appended to any external ref — prefixed references (^, *, /) and extension-scoped references. The planner splits each string on whitespace: the first token is the job reference, and the rest are forwarded as arguments.
When multiple steps schedule the same dependency with different flags, the planner upgrades to an unrestricted execution (no flags) so the result satisfies all dependents.
Job CLI script
Jobs are implemented as CLI scripts executed as subprocesses:
#!/usr/bin/env bun
// bin/build.ts
import {
runJobCommandCli,
emitLog,
type JobCommandContext,
type JobResult,
type StandardJobOptions,
CommandModel,
Flag,
} from '@putnami/sdk';
@CommandModel({ name: 'build', description: 'Build project' })
class BuildOptions implements StandardJobOptions {
@Flag({ flagName: '--putnamiContext', required: true })
putnamiContext = '';
@Flag({ flagName: '--output' })
output: 'text' | 'jsonl' | 'both' = 'jsonl';
@Flag({ flagName: '--help', shorts: ['-h'], required: false })
help = false;
}
async function runBuild(
_options: BuildOptions,
context: JobCommandContext
): Promise<JobResult> {
if (!context.project) {
return { status: 'SKIP' };
}
emitLog('info', `Building ${context.project.name}`);
// Build logic here
return { status: 'OK' };
}
if (import.meta.main) {
runJobCommandCli({
model: BuildOptions,
extension: '@my/extension',
job: 'build',
run: runBuild,
});
}Enable your extension
Install your extension at the workspace root:
bun add <your-extension>The extension will be auto-discovered and available immediately. You can also set workspace defaults in .putnamirc.json under options, or project-level defaults in a .putnamirc.json file under commands.
Next steps
- Previous: SDK
- Next: TypeScript