Skip to content

Your first schematic

Every step below is runnable exactly as written. You’ll initialise a workspace, scaffold a schematic, declare its typed inputs, write the factory, and execute it twice to see idempotency at work. Install the CLI and Bun first if you haven’t.

  1. Initialise a workspace (inside any project, new or existing):

    Terminal window
    builder init

    This creates project-builder.json (the workspace config), a schematics/ folder, and an AI skill under .claude/skills/pbuilder/: a command reference your coding agent picks up automatically. It also installs @pbuilder/sdk as a dev dependency, creating a package.json first if the folder doesn’t have one.

  2. Scaffold a schematic:

    Terminal window
    builder new schematic hello

    You get a registered, ready-to-edit package:

    schematics/hello/
    ├── schema.json # typed inputs — the contract with the user
    ├── schema.generated.ts # generated from schema.json — never edit
    └── factory.ts # your authoring logic

    project-builder.json now lists it under collections.default.hello.

  3. Declare the inputs. Replace the generated schema.json’s contents with the following; every property needs a label (codegen fails without it):

    schematics/hello/schema.json
    {
    "properties": {
    "name": { "type": "string", "label": "Service name", "required": true }
    }
    }

    Property types are string, number, boolean, and enum; default and description are optional. An enum currently needs its values under both choices (read by codegen and the SDK) and enum (read by builder execute), with the same values:

    { "type": "enum", "label": "Size", "choices": ["sm", "md"], "enum": ["sm", "md"] }
  4. Regenerate the input types:

    Terminal window
    bunx pbuilder-codegen schematics/hello

    This rewrites schema.generated.ts with an Input type derived from your schema, so your factory is typed against the schema, never against a hand-written shape. To regenerate every registered schematic at once, run bunx pbuilder-codegen with no arguments (see Type generation).

  5. Write the factory:

    schematics/hello/factory.ts
    import { create, find, replaceContent } from "@pbuilder/sdk/commons";
    import type { Input } from "./schema.generated.ts";
    export default async (input: Input) => {
    // create a new file
    create(`src/services/${input.name}.ts`, {
    template: `export const serviceName = "${input.name}";`,
    options: {},
    });
    // content-aware edit: read the tree, then create or update
    const existing = await find("services.txt").read();
    if (existing === undefined) {
    create("services.txt", { template: input.name, options: {} });
    } else {
    replaceContent("services.txt", `${existing}\n${input.name}`);
    }
    };

    The engine invokes the module’s default export, which is the factory. This one builds its output strings in TypeScript; the template language is the declarative alternative.

  6. Execute it. default:hello is <collection>:<schematic> as registered in project-builder.json; inputs are passed as CLI flags:

    Terminal window
    builder execute default:hello --name=payments
    ~ services.txt
    ~ src/services/payments.ts
    ✓ done — 2 modified

    (~ marks a path the run wrote.)

Now run it again with a different input:

Terminal window
builder execute default:hello --name=orders

services.txt grows by one line instead of being clobbered, because of the read-back loop from step 5. Re-running with the same name, on the other hand, rejects with path-collision: create is fail-closed on an existing path (pass force: true to overwrite deliberately), and since a failed run writes nothing, your tree is left exactly as it was.

  • Always pass options: {} to create, even when the template has no tokens. The type requires it, and in untyped call sites (plain JS, any) omitting it puts undefined in the directive batch, rejecting the write as unrepresentable.
  • find().read() is a trichotomy: undefined means the file is absent, "" means it exists and is empty. Branch on === undefined, never if (!content), which conflates the two.
  • Test it without the CLI or a running engine: Testing schematics.
  • Mutation verbs: everything a factory can do to the tree.
  • Templates: the declarative template language inside create().
  • Scaffolding: mirror a whole folder of templates instead of writing one create() per file.
  • Pending directives: preview a factory’s planned changes before anything commits.
  • Error handling: the structured AuthoringError contract of a rejected run.