Dagger Modules in Dang
Audience: people writing Dagger modules in Dang. For the language itself
(syntax, nullability, copy-on-write mutation, control flow, GraphQL interop)
see the dang-language skill; this skill covers only what is specific to Dang
as a Dagger module SDK.
Mental model
- A Dang module is a directory of
.dang files plus a config naming dang as
the SDK. All top-level .dang files in the source directory form one
module (subdirectories and other files are ignored); declarations are
order-independent across files.
- No codegen, no container — the engine interprets Dang natively. There is
no generated client: the Dagger API is auto-imported under the
Dagger
namespace, and bare names work too (container.from(...) ≡
Dagger.container.from(...)).
- Every public top-level declaration becomes module API:
type → object
(plus constructor), enum → enum, interface → interface. let keeps a
binding private. Custom scalars are exposed as strings.
- The main object is the type whose name matches the module name.
Secondary types get module-prefixed schema names:
type Widget in module
test lives in the schema as TestWidget.
- Self-calls are fully supported (always enabled for the Dang SDK — no
experimental flag needed): a module can call its own functions through the
engine via its own root binding (
test.foo, tuiQa). See the dedicated
section — the return-type annotation rule is the #1 gotcha.
Module setup
{
"name": "counter",
"engineVersion": "v0.21.5",
"sdk": { "source": "dang" }
}
"sdk": "dang" (plain string) also works.
engineVersion selects the Dang major: < v0.21.5 routes to frozen Dang v1
(where .{ } was GraphQL selection); >= v0.21.5 is Dang v2 (.{ } is
dot-block application, .{{ }} is selection). Don't bump an old module's
engineVersion without checking its selection syntax.
- Dependencies:
"dependencies": [{"name": "gochild", "source": "gochild"}] —
deps may use any SDK (Go, Python, TypeScript, Dang) side by side.
"disableDefaultFunctionCaching": true turns off default function caching
module-wide (coarse alternative to per-function @cache).
- Legacy
"sdk": {"experimental": {"SELF_CALLS": true}} is obsolete: self-calls
graduated to a runtime-capability check and are always on for Dang.
Main type, constructor & API surface
type Greeter {
let secret: String! = "hidden" # private state, never exposed
name: String! # public data field (also a ctor param)
new(name: String! = "world") { # explicit constructor
self.name = name.capitalize
self # must end with self
}
greet: String! { "Hey, " + name } # computed field / zero-arg function
}
- Without
new, the uninitialized public fields form an implicit constructor
in declaration order (positional construction works).
- Constructor args become top-level flags:
dagger call --name alice greet.
Arg names need not match field names; the body really executes.
- Public is the default: bare typed declarations are exposed;
let is
private — including let functions, the idiom for internal helpers. The
pub keyword is legacy: it still parses (as a no-op) and the formatter
strips it — don't write it in new code.
- A field with a body is a function/computed field; a plain typed field is
data. Docstrings (
"""...""" before a declaration or arg) become API
descriptions.
- Private (
let) fields persist across calls: they serialize into the
object's state and rehydrate on the next call. Chain state mutations
copy-on-write style: with(x): Self! { self.x = x; self }.
- Non-null args (
T!) are required; nullable args are optional — the idiom
for optional inputs is arg: File = null.
Map[...] cannot be exposed through the API; keep maps in let fields
(they serialize fine privately). Ad-hoc record types can't be exposed
either — declare a named type.
Void return marks an effect-only function; end the body with null (or a
Void-typed call), and use .sync to force container execution.
Self-calls
A self-call invokes the module's own API through the engine, via the
module's root binding (the module name in camelCase):
type Test {
containerEcho(msg: String!): Container! {
container.from("alpine").withExec(["echo", msg])
}
print(msg: String!): String! {
test.containerEcho(msg: msg).stdout # self-call
}
fresh: Dagger.Test! { test } # self-call the constructor
}
- Return-type annotation rule: a function that returns a self-call
result must declare the return as
Dagger.<SchemaTypeName>! — e.g.
Dagger.Test!, or Dagger.TestWidget! for a secondary Widget — not the
bare local type. A self-call yields the type as installed in the runtime
schema (namespaced, carrying a GraphQL id); the bare local type is a
different type. Annotating with the bare type makes the runtime receive a
raw ID string where an object is expected.
- Constructing locally (
Widget(x)) yields the bare local Widget!; only an
actual API call yields Dagger.TestWidget!.
- Self-call results are real objects: fields read back fine
(
test.fresh.getMessage), including on secondary types.
- Self-calls also work when the module is used as a dependency, transitively.
- Bare
test (zero-arg constructor auto-call) is the way to reset to a fresh
instance from inside a method.
Dependencies
- A dependency named
foo is callable as the root binding foo:
foo.curve(...), dangchild.value. Deps with constructor args are called
like functions: engineDev(ws: source).test.
- A dependency's types appear module-namespaced: dep
foo's enum EcCurve
is FooEcCurve, dep dep's interface Greeter is DepGreeter.
Enums, interfaces, scalars
enum Status { PENDING RUNNING DONE } — compare with ==; CLI passes
members verbatim (--status DONE).
interface Local { greet(name: String!): String! } plus
type Hey implements Local — implementers must not declare the
synthesized id: ID! field Dagger adds to every interface.
- Structural conformance crosses module boundaries: an object matching a dep
interface's shape passes as that interface without
implements.
- Interface methods touching core types annotate them qualified:
apply(container: Dagger.Container!): Dagger.Container!.
scalar Timestamp is exposed as a String at the boundary; values arrive as
strings.
Directives Dagger consumes
Function-level: @check (marks a check; typically on Void returns),
@generate (on Changeset-returning generators), @up (on Service!),
@agent (see below), @cache.
Arg-level: @defaultPath(path: ...) on Directory! args — relative paths
resolve against the module, "/" against the context root;
@ignorePatterns(patterns: [...]) filters with gitignore-style patterns
(allowlisting via "!keep" works). Positional and named args both parse.
Placement: suffix (screen: String! @cache(...)) or prefix on the line
before the declaration.
Agent idiom:
agent(base: LLM!): LLM! @agent {
base.withTools(currentNode).withSystemPrompt(systemPrompt)
}
Workspace args
- A
Workspace!-typed arg (bare or Dagger.Workspace!) is auto-filled by the
caller's workspace — no flag needed on dagger call; for agents it's filled
from the bound workspace and hidden from the model.
let ws: Workspace! as an uninitialized field is the standard pattern for
holding it. Read with ws.file(...), ws.directory(path, exclude: [...]).
- The mounted workspace is a plain snapshot with no
.git — git diff
won't work; use Workspace.git.uncommitted (a Changeset) with
.diffStats / .asPatch.
Caching pitfalls
- The engine memoizes function results by (object id, field, args) within a
session. Side-effecting or live-reading functions must opt out:
@cache(policy: FunctionCachePolicy.Never) (mixes a per-call nonce into the
call id). @cache(ttl: 300) sets a time-to-live instead.
- Even with
Never, identical container execs still hit the exec cache — bust
with a nonce: .withEnvVariable("NONCE", Random.string).
Shadowing core types
- A module may declare types shadowing core names (
type Container); the bare
name then means the local type, and Dagger.Container! / Dagger.container
disambiguates back to core.
Pitfalls checklist
- Self-call return annotated with the bare local type instead of
Dagger.<T>! → runtime gets a raw ID string. (Self-calls DO work — don't
conclude otherwise from old comments.)
- Missing
@cache(policy: FunctionCachePolicy.Never) on a stateful/live tool
→ the second call replays the first result.
- Exposing a
Map[...] or an ad-hoc record type → hard error.
- Declaring
id when implementing a dep interface → error; omit it.
- Using v1
.{ } selection in a >= v0.21.5 module — that's dot-block now;
select with .{{ }}.
- Only top-level type declarations become module types; types defined inside
bodies aren't hoisted into the schema.
1---2name: dang-dagger-modules3description: Authoring Dagger modules in Dang — dagger.json setup, the main type & constructor, what becomes module API, self-calls (supported; annotate returns as Dagger.<Type>!), dependencies, enums/interfaces/scalars, directives (@check, @cache, @defaultPath, @agent), Workspace args, and caching pitfalls. Use when writing or reviewing a Dagger module implemented in Dang.4---5
6# Dagger Modules in Dang
7
8**Audience: people writing Dagger modules in Dang.** For the language itself
9(syntax, nullability, copy-on-write mutation, control flow, GraphQL interop)
10see the `dang-language` skill; this skill covers only what is specific to Dang
11as a **Dagger module SDK**.
12
13## Mental model
14
15- A Dang module is a directory of `.dang` files plus a config naming `dang` as
16 the SDK. **All** top-level `.dang` files in the source directory form one
17 module (subdirectories and other files are ignored); declarations are
18 order-independent across files.
19- **No codegen, no container** — the engine interprets Dang natively. There is
20 no generated client: the Dagger API is auto-imported under the `Dagger`
21 namespace, and bare names work too (`container.from(...)` ≡
22 `Dagger.container.from(...)`).
23- Every **public top-level declaration** becomes module API: `type` → object
24 (plus constructor), `enum` → enum, `interface` → interface. `let` keeps a
25 binding private. Custom `scalar`s are exposed as strings.
26- The **main object** is the type whose name matches the module name.
27 Secondary types get module-prefixed schema names: `type Widget` in module
28 `test` lives in the schema as `TestWidget`.
29- **Self-calls are fully supported** (always enabled for the Dang SDK — no
30 experimental flag needed): a module can call its own functions through the
31 engine via its own root binding (`test.foo`, `tuiQa`). See the dedicated
32 section — the return-type annotation rule is the #1 gotcha.
33
34## Module setup
35
36```json
37{
38 "name": "counter",
39 "engineVersion": "v0.21.5",
40 "sdk": { "source": "dang" }
41}
42```
43
44- `"sdk": "dang"` (plain string) also works.
45- `engineVersion` selects the Dang major: `< v0.21.5` routes to frozen Dang v1
46 (where `.{ }` was GraphQL selection); `>= v0.21.5` is Dang v2 (`.{ }` is
47 dot-block application, `.{{ }}` is selection). Don't bump an old module's
48 `engineVersion` without checking its selection syntax.
49- Dependencies: `"dependencies": [{"name": "gochild", "source": "gochild"}]` —
50 deps may use any SDK (Go, Python, TypeScript, Dang) side by side.
51- `"disableDefaultFunctionCaching": true` turns off default function caching
52 module-wide (coarse alternative to per-function `@cache`).
53- Legacy `"sdk": {"experimental": {"SELF_CALLS": true}}` is obsolete: self-calls
54 graduated to a runtime-capability check and are always on for Dang.
55
56## Main type, constructor & API surface
57
58```dang
59type Greeter {
60 let secret: String! = "hidden" # private state, never exposed
61 name: String! # public data field (also a ctor param)
62
63 new(name: String! = "world") { # explicit constructor
64 self.name = name.capitalize
65 self # must end with self
66 }
67
68 greet: String! { "Hey, " + name } # computed field / zero-arg function
69}
70```
71
72- Without `new`, the uninitialized public fields form an implicit constructor
73 in declaration order (positional construction works).
74- Constructor args become top-level flags: `dagger call --name alice greet`.
75 Arg names need not match field names; the body really executes.
76- Public is the default: bare typed declarations are exposed; `let` is
77 private — including `let` *functions*, the idiom for internal helpers. The
78 `pub` keyword is legacy: it still parses (as a no-op) and the formatter
79 strips it — don't write it in new code.
80- A field with a body is a function/computed field; a plain typed field is
81 data. Docstrings (`"""..."""` before a declaration or arg) become API
82 descriptions.
83- Private (`let`) fields persist across calls: they serialize into the
84 object's state and rehydrate on the next call. Chain state mutations
85 copy-on-write style: `with(x): Self! { self.x = x; self }`.
86- Non-null args (`T!`) are required; nullable args are optional — the idiom
87 for optional inputs is `arg: File = null`.
88- `Map[...]` **cannot** be exposed through the API; keep maps in `let` fields
89 (they serialize fine privately). Ad-hoc record types can't be exposed
90 either — declare a named `type`.
91- `Void` return marks an effect-only function; end the body with `null` (or a
92 `Void`-typed call), and use `.sync` to force container execution.
93
94## Self-calls
95
96A self-call invokes the module's **own** API through the engine, via the
97module's root binding (the module name in camelCase):
98
99```dang
100type Test {
101 containerEcho(msg: String!): Container! {
102 container.from("alpine").withExec(["echo", msg])
103 }
104
105 print(msg: String!): String! {
106 test.containerEcho(msg: msg).stdout # self-call
107 }
108
109 fresh: Dagger.Test! { test } # self-call the constructor
110}
111```
112
113- **Return-type annotation rule:** a function that *returns* a self-call
114 result must declare the return as `Dagger.<SchemaTypeName>!` — e.g.
115 `Dagger.Test!`, or `Dagger.TestWidget!` for a secondary `Widget` — not the
116 bare local type. A self-call yields the type *as installed in the runtime
117 schema* (namespaced, carrying a GraphQL id); the bare local type is a
118 different type. Annotating with the bare type makes the runtime receive a
119 raw ID string where an object is expected.
120- Constructing locally (`Widget(x)`) yields the bare local `Widget!`; only an
121 actual API call yields `Dagger.TestWidget!`.
122- Self-call results are real objects: fields read back fine
123 (`test.fresh.getMessage`), including on secondary types.
124- Self-calls also work when the module is used as a dependency, transitively.
125- Bare `test` (zero-arg constructor auto-call) is the way to reset to a fresh
126 instance from inside a method.
127
128## Dependencies
129
130- A dependency named `foo` is callable as the root binding `foo`:
131 `foo.curve(...)`, `dangchild.value`. Deps with constructor args are called
132 like functions: `engineDev(ws: source).test`.
133- A dependency's types appear module-namespaced: dep `foo`'s `enum EcCurve`
134 is `FooEcCurve`, dep `dep`'s `interface Greeter` is `DepGreeter`.
135
136## Enums, interfaces, scalars
137
138- `enum Status { PENDING RUNNING DONE }` — compare with `==`; CLI passes
139 members verbatim (`--status DONE`).
140- `interface Local { greet(name: String!): String! }` plus
141 `type Hey implements Local` — implementers must **not** declare the
142 synthesized `id: ID!` field Dagger adds to every interface.
143- Structural conformance crosses module boundaries: an object matching a dep
144 interface's shape passes as that interface without `implements`.
145- Interface methods touching core types annotate them qualified:
146 `apply(container: Dagger.Container!): Dagger.Container!`.
147- `scalar Timestamp` is exposed as a String at the boundary; values arrive as
148 strings.
149
150## Directives Dagger consumes
151
152- Function-level: `@check` (marks a check; typically on `Void` returns),
153 `@generate` (on Changeset-returning generators), `@up` (on `Service!`),
154 `@agent` (see below), `@cache`.
155- Arg-level: `@defaultPath(path: ...)` on `Directory!` args — relative paths
156 resolve against the module, `"/"` against the context root;
157 `@ignorePatterns(patterns: [...])` filters with gitignore-style patterns
158 (allowlisting via `"!keep"` works). Positional and named args both parse.
159- Placement: suffix (`screen: String! @cache(...)`) or prefix on the line
160 before the declaration.
161- Agent idiom:
162
163 ```dang
164 agent(base: LLM!): LLM! @agent {
165 base.withTools(currentNode).withSystemPrompt(systemPrompt)
166 }
167 ```
168
169## Workspace args
170
171- A `Workspace!`-typed arg (bare or `Dagger.Workspace!`) is auto-filled by the
172 caller's workspace — no flag needed on `dagger call`; for agents it's filled
173 from the bound workspace and hidden from the model.
174- `let ws: Workspace!` as an uninitialized field is the standard pattern for
175 holding it. Read with `ws.file(...)`, `ws.directory(path, exclude: [...])`.
176- The mounted workspace is a plain snapshot with **no `.git`** — `git diff`
177 won't work; use `Workspace.git.uncommitted` (a Changeset) with
178 `.diffStats` / `.asPatch`.
179
180## Caching pitfalls
181
182- The engine memoizes function results by (object id, field, args) within a
183 session. Side-effecting or live-reading functions **must** opt out:
184 `@cache(policy: FunctionCachePolicy.Never)` (mixes a per-call nonce into the
185 call id). `@cache(ttl: 300)` sets a time-to-live instead.
186- Even with `Never`, identical container execs still hit the exec cache — bust
187 with a nonce: `.withEnvVariable("NONCE", Random.string)`.
188
189## Shadowing core types
190
191- A module may declare types shadowing core names (`type Container`); the bare
192 name then means the local type, and `Dagger.Container!` / `Dagger.container`
193 disambiguates back to core.
194
195## Pitfalls checklist
196
197- Self-call return annotated with the bare local type instead of
198 `Dagger.<T>!` → runtime gets a raw ID string. (Self-calls DO work — don't
199 conclude otherwise from old comments.)
200- Missing `@cache(policy: FunctionCachePolicy.Never)` on a stateful/live tool
201 → the second call replays the first result.
202- Exposing a `Map[...]` or an ad-hoc record type → hard error.
203- Declaring `id` when implementing a dep interface → error; omit it.
204- Using v1 `.{ }` selection in a `>= v0.21.5` module — that's dot-block now;
205 select with `.{{ }}`.
206- Only *top-level* type declarations become module types; types defined inside
207 bodies aren't hoisted into the schema.