A Dagger SDK for authoring Dagger modules and generating typed clients in TypeScript.
This module implements the Dagger CLI 1.0 SDK provider interface. The engine
records SDK scopes in dagger.toml, sets the workspace cwd to one, and asks
this module for that scope's complete desired state through findClientRoot and
generateScope. The module writes the files it owns; the engine owns the
workspace bookkeeping, and its builtin TypeScript runtime executes what we
write.
Workspace state, module discovery and codegen go through the engine's native
Workspace and ModuleSource APIs, which requires an engine at
v1.0.0-beta.11 or newer. Module manifests are built with
dagger/sdk-helpers, the one module
dependency.
Install the TypeScript SDK into your workspace:
dagger module install github.com/dagger/typescript-sdkThe engine inspects the installed module. Because it implements the complete
SDK-module interface, the engine also records it as an SDK, so dagger module init typescript and dagger module client add typescript dispatch to it. List
what is registered with dagger sdk list.
Commands that write to the workspace print the diff and prompt for confirmation.
Pass --auto-apply to skip the prompt.
Create a TypeScript SDK module under the default .dagger/modules/<name>/:
dagger module init typescript --name my-modulePick a different location with --path:
dagger module init typescript --name my-module --path some/dir/my-moduleInit writes the module's dagger-module.toml, seeds src/index.ts from a
template, and generates the module's own bindings, dispatch entrypoint and
runtime config in one step — there is no separate dagger generate afterwards.
The runtime decides which config files a module gets:
node/bun→package.json,tsconfig.jsondeno→deno.json
Init never removes what is already at the target path. If package.json,
tsconfig.json, or deno.json are there, it merges Dagger-required keys into
them rather than overwriting — your scripts, path aliases, unstable flags, and
other custom settings are preserved — and any other file is left untouched.
A scope whose module is still configured by a pre-1.0 dagger.json is migrated
on its first generation: the manifest is rewritten as dagger-module.toml, with
source, include and [[dependencies]] carried over, and the dagger.json is
removed. The module's own source is generated, never scaffolded over. Its
engineVersion is not carried over: a module is served the core API of the
release it pins, and a pre-1.0 pin yields bindings without serveModule, which
the generated clients call — so the migrated manifest is pinned to the release
this SDK's bundle is built for, and a dagger-module.toml still pinned to a
pre-1.0 release (dagger setup keeps the old pin) is raised the same way on its
next generation. The /sdk and /__dagger.entrypoint.ts lines the pre-1.0
engine put in the module's .gitignore go the same way — a 1.0 module commits
those files, and a stale line hides them from the engine — while every other
line stays.
The SDK's settings become flags on dagger module init and dagger module client add, and are persisted per scope in dagger.toml:
| Setting | Flag | Default |
|---|---|---|
runtime |
--runtime |
detected from the scope's config files, else node |
template |
--template |
default (a small working module; empty is a bare @object class) |
packageManager |
--package-manager |
unset |
baseImage |
--base-image |
unset |
legacyRuntime |
--legacy-runtime |
false (the module declares an [entrypoint]) |
dagger module init typescript --name my-module --runtime bun
dagger module init typescript --name my-module --template empty
dagger module init typescript --name my-module \
--package-manager pnpm@8.15.4 \
--base-image node:23.2.0-alpineruntime is detected rather than defaulted, so adopting an existing project, or
regenerating a module created before the setting existed, does not silently move
a Bun or Deno project onto Node. Setting it moves the scope to that runtime on
the next generation. A switch adds the new runtime's config files and leaves the
old ones in place — a Node module moved to Deno gains a deno.json and keeps
its package.json and tsconfig.json until you remove them — because
generation never removes what it did not write.
--package-manager accepts the Node-standard name@version syntax (e.g.
npm@10.7.0, pnpm@8.15.4, yarn@1.22.22). It is only valid with the Node
runtime; Bun and Deno bundle their own.
--base-image writes to deno.json for Deno modules and to package.json
otherwise — matching where the engine reads it from.
--legacy-runtime picks the loading path, and a module is on one of them: by
default it declares an [entrypoint] and carries entrypoint/main.dang plus
the __dagger.dispatch.ts that program runs, which needs an engine carrying
dagger/dagger#14038 — v1.0.0-beta.15 or newer. Pass the flag
for an older engine and the module declares a [runtime] with the
__dagger.entrypoint.ts that runtime execs instead.
An existing module moves between the two on its next generation: set
legacy-runtime in the scope's settings in dagger.toml and run dagger generate. Onto an entrypoint, the manifest drops engineVersion, source,
include and [[dependencies]] with the [runtime] they hang off. Back onto a
runtime, the manifest is written fresh — engineVersion pinned to the release
this SDK ships for, [[dependencies]] rebuilt from the scope's clients — and
entrypoint/, __dagger.dispatch.ts and the clients/ packages are removed. A
source or include the original manifest carried is not restored.
Pass it on a current engine too, for now: #14038 gates the entrypoint path on a
manifestVersion that the manifest builder has no field for, so an
[entrypoint] manifest written here reads as a runtime-less module and does not
load anywhere yet. The default is what the generated tree should be; the flag
is what runs today.
Record a client for a module in the current scope:
dagger module client add typescript .dagger/modules/api
dagger module client add typescript github.com/acme/paymentsThere is no path argument: the SDK picks the layout. Which scope the client
lands in comes from findClientRoot, which answers with the directory of the
nearest package.json, deno.json, deno.jsonc or tsconfig.json above your
cwd — so a client belongs to the TypeScript project you are standing in. A
directory with no TypeScript project above it is not a scope this SDK can claim.
What a scope gets depends on what the scope is:
| Scope | Client output |
|---|---|
| A module | sdk/ (the library) plus clients/ (its per-module clients) |
| Your own project | clients/ — the vendored library plus one package per target, wired through your package.json |
A module keeps its targets in a clients/ directory beside the library:
every module — a dependency, a recorded target, the module itself — becomes its
own clients/<module>.gen.ts client with its own dag and entrypoint
functions, reached through its own specifier. sdk/ stays the core-only
@dagger.io/dagger library:
import { dag, Container } from "@dagger.io/dagger" // core API (sdk/)
import { api } from "@dagger.io/api" // a bound module (clients/api.gen.ts)
api().deploy(dag.container().from("alpine"))The @dagger.io/<module> aliases are written into tsconfig.json (or
deno.json) at generation, so a client added inside a module is usable from it
straight away.
A standalone client scope renders the same client files as a module, packaged as real npm packages under the scope:
<scope>/
package.json yours; gains a file: dependency on each package below
clients/
dagger/ the vendored @dagger.io/dagger library
<module>/ one package per target, named @dagger.io/<module>
The scope's package.json is the link. The SDK writes
"@dagger.io/dagger": "file:./clients/dagger" and one
"@dagger.io/<module>": "file:./clients/<module>" per target into its
dependencies, pins typescript unless you declare one in any section, and
sets "type": "module" when the field is absent. That is the whole edit: every
other key is yours, an explicit "type" — "commonjs" included — stays, and
you run the install. Each client's own file: dependencies pull the library
(and any sibling it references) along:
npm installimport { connection, dag } from "@dagger.io/dagger"
import { api } from "@dagger.io/api"
await connection(async () => {
console.log(await api(dag.currentWorkspace()).deploy(dag.container().from("alpine")))
})Run it through dagger run, which opens the session connection attaches to:
dagger run -- npx -y tsx main.tsThe -- is needed: dagger run parses flags after the command, so -y would
be read as its own. And it is tsx rather than node because the packages
ship .ts sources with .js import specifiers, which plain node main.ts
rejects with ERR_MODULE_NOT_FOUND.
No path aliases, no remote @dagger.io/dagger dependency — plain package
resolution against the vendored, offline tree. The same packages can later come
from a registry instead of a file: link.
clients is the complete desired set, so dagger module client rm is just
regeneration without that target: its <module>/ package goes, and the last
target leaving takes the vendored dagger/ library and the scope's file:
dependencies with it. Only what the SDK generates is removed — every *.gen.ts
under clients/, a package's package.json, the library's bundle files — and a
directory only once that leaves nothing in it. A file of your own under
clients/, beside the packages or inside one, stays, and so does the directory
holding it.
dagger generateThe engine reads the scope list, orders it so a module is generated before anything holding a client for it, threads each result into the next, and calls this SDK once per scope.
Run the checks:
dagger checke-2-e:* drives this SDK's functions the way the engine does, one file per
surface (discovery, init, generate, client), sharing the
assertions in util.dang and the fixture tree under
.dagger/modules/e2e/fixtures. runtimes:* generates a module per JavaScript
runtime and loads it. List them with dagger check -l, or run one group with
dagger check "e-2-e:discovery:*".
engine-e-2-e:* covers the half no dang check can reach: it builds an engine
from dagger/dagger#13992, runs it as a playground with this checkout mounted,
and drives the real CLI through sdk list, module init with and without
settings, call, and the whole check suite. Provider validation is silent when
it fails — the engine simply never records [sdks.typescript] — so this is what
tells you the interface still matches. Bumping the branch means changing both
the engine-dev dependency in .dagger/modules/engine-e2e/dagger-module.toml
and engineCommit in .dagger/modules/engine-e2e/main.dang.
See typescript-sdk.dang for the full type surface and
design/module-max.md for why it is shaped this way.