Dagger Managed Environments: @dagger.io/dagger

· combray's blog


Summary #

For programmatically controlling isolated environments with TypeScript, @dagger.io/dagger is the standard and most robust choice. It provides a strongly-typed, low-level SDK that allows an agent to construct, manipulate, and execute containers as if they were local objects.

Unlike higher-level abstractions that might give you a "pre-baked" dev environment, using the SDK directly gives your agent "God mode" over the container: it can swap base images, mount specific directories, inject secrets, and execute arbitrary commands, all while keeping the host system completely safe[1].

Philosophy & Mental Model #

The most critical concept for an Agent to understand is Immutability.

  1. Chains, Not Shells: You don't "open a terminal" and type commands. You take a Container object and apply a transformation (like .withExec()) to get a new Container object.
  2. Lazy Execution: Defining the pipeline does nothing. The container is only built and commands run when you call a terminal method like .stdout(), .sync(), or .export().
  3. State Passing: To "keep" the state of a session (e.g., after npm install), the Agent must hold onto the resulting container object and use it as the base for the next command.

The Mental Model: Instead of: SSH -> Run Command -> Wait -> Run Next Command Think: Base State + Command A = State A; then State A + Command B = State B.

Setup #

The agent needs the NPM package, but the host machine must have the Dagger engine installed.

1. Install Dagger Engine (Host) #

1# On the machine running the agent:
2curl -L https://dl.dagger.io/dagger/install.sh | sh

2. Install SDK #

1npm install @dagger.io/dagger

Core Usage Patterns #

Pattern 1: The "Stateful" Session #

This simulates a persistent workspace where the agent runs multiple commands in sequence, preserving filesystem changes.

 1import { dag, Container, Directory } from "@dagger.io/dagger";
 2
 3async function runSession() {
 4  // 1. Initialize a base container (e.g., Node.js)
 5  let session: Container = dag
 6    .container()
 7    .from("node:22-alpine")
 8    .withWorkdir("/app");
 9
10  // 2. "Write" code (simulated by mounting or creating a file)
11  // In a real agent, you might mount a directory from the host
12  session = session.withNewFile("index.js", "console.log('Hello from Dagger');");
13
14  // 3. Run a command (State A -> State B)
15  // We capture the NEW container state
16  session = session.withExec(["node", "index.js"]);
17
18  // 4. Extract output (triggers execution)
19  const output = await session.stdout();
20  console.log("Agent received:", output); // "Hello from Dagger"
21
22  // 5. Continue the session (State B -> State C)
23  session = session.withExec(["touch", "test-passed.txt"]);
24  
25  // Verify file exists
26  const files = await session.directory(".").entries();
27  console.log("Files:", files); // ['index.js', 'test-passed.txt']
28}

Pattern 2: Dynamic Tool Construction #

The agent can build its own tools on the fly. If it needs ffmpeg but the container doesn't have it, it installs it.

 1async function executeWithTool(toolName: string, args: string[]) {
 2  // Agent decides it needs a specific environment
 3  let ctr = dag.container().from("ubuntu:latest");
 4
 5  // Dynamically install dependencies
 6  ctr = ctr
 7    .withExec(["apt-get", "update"])
 8    .withExec(["apt-get", "install", "-y", toolName]);
 9
10  // Run the requested tool
11  ctr = ctr.withExec([toolName, ...args]);
12
13  return await ctr.stdout();
14}

Pattern 3: Safe File Extraction #

When the agent writes code or artifacts inside the container, retrieve them safely without exposing the host fs directly.

1async function retrieveArtifact(container: Container, path: string) {
2  // Get a reference to the file inside the container
3  const file = container.file(path);
4  
5  // Read content directly into memory (good for text)
6  const content = await file.contents();
7  return content;
8}

Anti-Patterns & Pitfalls #

❌ Don't: Ignoring the Return Value #

Dagger objects are immutable. Modifying them returns a new object.

1// BAD
2const ctr = dag.container().from("alpine");
3ctr.withExec(["echo", "hello"]); // THIS DOES NOTHING to 'ctr'
4await ctr.stdout(); // Error or empty output, because 'ctr' is still just the base image

✅ Instead: Chain or Reassign #

1// GOOD
2let ctr = dag.container().from("alpine");
3ctr = ctr.withExec(["echo", "hello"]); // Update the reference
4await ctr.stdout();

❌ Don't: treating withExec as "Run Now" #

withExec configures the plan. It does not execute until you await a result.

1// BAD
2ctr.withExec(["long-process"]); // Returns instantly
3console.log("Done?"); // Prints immediately, process hasn't run

✅ Instead: Await a Terminal Operation #

1// GOOD
2// .sync() forces execution without returning output data
3await ctr.withExec(["long-process"]).sync(); 
4console.log("Done!");

Caveats #

References #

[1] Dagger TypeScript SDK Reference - Documentation [2] Dagger API Concepts - Mental Model [3] Dagger NPM Package - npm

last updated: