> ## Documentation Index
> Fetch the complete documentation index at: https://docs.asteroid.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Version an agent in git

> Keep an agent's definition in your repository, and publish it from CI only when it changes.

Some Asteroid users like to keep their workflow definitions versioned in their own git repository.

An agent is a directory of files. The API renders that directory, and it accepts the directory back.

So you keep an agent in git the same way you keep code in git: write the files to disk, commit them, and review a diff.

A **push** step, in CI or on your machine, makes the published agent match the repository and publishes the new version, so later API calls run it.

Two values track change: the **agent id** says which agent you are pushing to, and the **content hash** says whether it still matches your checkout. Keep both in a lockfile beside the directory.

## The directory

A workflow renders as a small tree. Each node gets a directory:

```
settings.yaml                            # the workflow's own settings
input-schema.json                        # the inputs the agent accepts
nodes/Login/settings.yaml                # the node's type, model and capabilities
nodes/Login/instructions.md              # the prompt
nodes/Login/script.js                    # the node's script, when it has one
nodes/Login/.node-id                     # the node's identity (hidden)
nodes/Done/output-schema.json            # the output schema
sticky_notes/sticky_note_1/content.md    # canvas annotations
sticky_notes/sticky_note_1/.sticky-note-id
```

Two kinds of file live in the tree.

* **Structural** files define the agent. Their content comes back inline, and writing one back rebuilds the graph.
* **Agent files** are the agent's own files: memory, uploads, and whatever a node keeps beside itself. Each comes back with a `checksum` and a `downloadUrl`. The bytes come inline only if you ask for them.

<Warning>
  **Commit the dotfiles, and push them.** `.node-id` and `.sticky-note-id` hold each node's identity. Rename a node and its directory moves; the id file is what makes that a rename instead of a delete and an add.

  Most glob libraries skip dotfiles unless you ask for them (`fast-glob` and `globby` need `dot: true`). If your push misses them, it deletes them.
</Warning>

## The endpoints

| Step                      | Call                                                 | Returns                                     |
| ------------------------- | ---------------------------------------------------- | ------------------------------------------- |
| Read the editable head    | `GET /agents/{agentId}/workflow-head/files`          | The directory, with `rev` and `contentHash` |
| Read a published version  | `GET /agents/{agentId}/workflows/{workflowId}/files` | The same shape, for one version             |
| Write the directory back  | `PATCH /agents/{agentId}/workflow-head/files`        | The updated head                            |
| Publish the head          | `POST /agents/{agentId}/workflow-head/publish`       | `{ version, workflowId }`                   |
| Run the published version | `POST /agents/{agentId}/execute`                     | `{ executionId }`                           |

Three things are worth knowing:

* **The patch is rev-guarded.** Send the `rev` you read as `baseRev`. If it is stale you get a 409, so you cannot overwrite an edit someone made in the builder while you were working.
* **Publishing assigns the next version** and unpublishes the previous one. Only one version is published at a time.
* **`POST /agents/{agentId}/execute` with no `version` runs the published version** — which, after a push, is what is in your repository.

## Pull

Write the tree to disk, then commit it.

<CodeGroup>
  ```ts TypeScript theme={null}
  // npm install asteroid-odyssey
  import { createHash } from 'node:crypto';
  import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
  import { dirname, join, relative } from 'node:path';
  import { client, agentWorkflowHeadGetFiles } from 'asteroid-odyssey';

  client.setConfig({
    baseUrl: 'https://odyssey.asteroid.ai/agents/v2',
    headers: { 'X-Api-Key': process.env.ASTEROID_API_KEY! },
  });

  // Every file under root, including .node-id and .sticky-note-id. Do not swap
  // this for a glob unless it is configured to return dotfiles: an id file left
  // out of the listing is deleted on push, and the node it identifies is
  // recreated as new.
  async function listFiles(dir: string, root = dir): Promise<string[]> {
    const entries = await readdir(dir, { withFileTypes: true });
    const nested = await Promise.all(
      entries.map((e) =>
        e.isDirectory()
          ? listFiles(join(dir, e.name), root)
          : Promise.resolve([relative(root, join(dir, e.name))]),
      ),
    );
    return nested.flat();
  }

  // The checksum of what is already on disk, or null when nothing is.
  async function localChecksum(path: string): Promise<string | null> {
    try {
      return createHash('sha256').update(await readFile(path)).digest('hex');
    } catch {
      return null;
    }
  }

  export async function pull(agentId: string, root: string) {
    // The default mode: structural files arrive inline, agent files stay
    // references carrying a checksum. Asking for `all` would ship every blob as
    // base64 whether or not you already hold it.
    const { data, error } = await agentWorkflowHeadGetFiles({ path: { agentId } });
    if (error) throw new Error(`pull failed: ${JSON.stringify(error)}`);

    // Reconcile, do not wipe. Deleting only what the manifest no longer lists
    // drops a node deleted in the builder — which a write-only pull would leave
    // on disk for the next push to re-create — while keeping the blobs you hold.
    const remote = new Set(data!.files.map((f) => f.path));
    for (const path of await listFiles(root).catch(() => [])) {
      if (!remote.has(path)) await rm(join(root, path));
    }

    for (const file of data!.files) {
      const target = join(root, file.path);
      await mkdir(dirname(target), { recursive: true });

      // Structural content is inline already, so writing it costs nothing.
      if (file.content !== undefined) {
        await writeFile(target, file.content, 'utf-8');
        continue;
      }
      // An agent file you already hold needs no request at all.
      if (file.checksum && (await localChecksum(target)) === file.checksum) continue;
      if (file.contentBase64 !== undefined) {
        await writeFile(target, Buffer.from(file.contentBase64, 'base64'));
        continue;
      }
      // Never silently skip: a missing file becomes a deletion on the next push.
      if (!file.downloadUrl) throw new Error(`no content for ${file.path}`);
      const blob = await fetch(file.downloadUrl, {
        headers: { 'X-Api-Key': process.env.ASTEROID_API_KEY! },
      });
      if (!blob.ok) throw new Error(`download failed for ${file.path}: ${blob.status}`);
      await writeFile(target, Buffer.from(await blob.arrayBuffer()));
    }
    return { rev: data!.rev, contentHash: data!.contentHash };
  }
  ```

  ```python Python theme={null}
  # pip install asteroid-odyssey
  import base64, hashlib, os
  from pathlib import Path

  import requests
  from asteroid_odyssey import ApiClient, Configuration
  from asteroid_odyssey.api.files_api import FilesApi

  KEY = os.environ["ASTEROID_API_KEY"]
  config = Configuration(api_key={"ApiKeyAuth": KEY})


  def local_checksum(path: Path) -> str | None:
      """The checksum of what is already on disk, or None when nothing is."""
      if not path.is_file():
          return None
      return hashlib.sha256(path.read_bytes()).hexdigest()


  def pull(agent_id: str, root: Path):
      # The default mode: structural files arrive inline, agent files stay
      # references carrying a checksum.
      with ApiClient(config) as api_client:
          tree = FilesApi(api_client).agent_workflow_head_get_files(agent_id=agent_id)

      # Reconcile, do not wipe: drop what the manifest no longer lists, and keep
      # the blobs you already hold.
      remote = {file.path for file in tree.files}
      for path in root.rglob("*"):
          if path.is_file() and str(path.relative_to(root)) not in remote:
              path.unlink()

      for file in tree.files:
          target = root / file.path
          target.parent.mkdir(parents=True, exist_ok=True)

          if file.content is not None:
              target.write_text(file.content, encoding="utf-8")
          elif file.checksum and local_checksum(target) == file.checksum:
              continue  # already held, no request needed
          elif file.content_base64 is not None:
              target.write_bytes(base64.b64decode(file.content_base64))
          elif file.download_url:
              # Over the inline budget. Fetch the blob itself.
              blob = requests.get(file.download_url, headers={"X-Api-Key": KEY})
              blob.raise_for_status()
              target.write_bytes(blob.content)
          else:
              raise RuntimeError(f"no content for {file.path}")

      return tree.rev, tree.content_hash
  ```
</CodeGroup>

## Push

Send the files that changed, then publish. The patch takes whole files, so send each file you hold and list the paths you deleted.

Anything remote that your listing does not name is deleted, so the listing has to be complete — dotfiles included. `readdir` returns them; most glob libraries do not.

<Warning>
  **A push overwrites work done in the platform.** Your repository wins: anything edited in the builder since your last pull is replaced, and anything the builder added that your listing does not name is deleted.

  Pull before you push, and compare the content hash first (see [Detect drift](#detect-drift-with-the-content-hash)). If it does not match the hash your last push returned, someone has changed the agent in the platform — pull and reconcile before pushing over it.
</Warning>

<CodeGroup>
  ```ts TypeScript theme={null}
  import { readFile } from 'node:fs/promises';
  import { join } from 'node:path';
  import {
    agentWorkflowHeadGetFiles,
    agentWorkflowHeadPatchFiles,
    agentWorkflowHeadPublishHead,
    agentWorkflowsGetFilesByVersion,
  } from 'asteroid-odyssey';

  // listFiles is the dotfile-safe walk from the pull example above.
  export async function push(agentId: string, root: string) {
    const localPaths = await listFiles(root);

    const { data: head, error: readError } = await agentWorkflowHeadGetFiles({
      path: { agentId },
      query: { contents: 'none' }, // only the manifest is needed to get `rev`
    });
    if (readError) throw new Error(`read failed: ${JSON.stringify(readError)}`);

    const remotePaths = new Set(head!.files.map((f) => f.path));
    const writes = await Promise.all(
      localPaths.map(async (path) => ({
        path,
        contentBase64: (await readFile(join(root, path))).toString('base64'),
      })),
    );
    const deletes = [...remotePaths].filter((path) => !localPaths.includes(path));

    const { error: patchError } = await agentWorkflowHeadPatchFiles({
      path: { agentId },
      body: { baseRev: head!.rev, writes, deletes },
    });
    if (patchError) throw new Error(`patch failed: ${JSON.stringify(patchError)}`);

    const { data: published, error: publishError } = await agentWorkflowHeadPublishHead({
      path: { agentId },
    });
    if (publishError) throw new Error(`publish failed: ${JSON.stringify(publishError)}`);

    // Read the published version back for its hash. This is the value to record
    // against the commit — the drift check below compares against it.
    const { data: tree, error: hashError } = await agentWorkflowsGetFilesByVersion({
      path: { agentId, workflowId: published!.workflowId },
      query: { contents: 'none' },
    });
    if (hashError) throw new Error(`hash read failed: ${JSON.stringify(hashError)}`);

    return { version: published!.version, contentHash: tree!.contentHash };
  }
  ```
</CodeGroup>

<Warning>
  `baseRev` guards one edit, not one deploy. Read the head immediately before you patch it. A `rev` you read minutes earlier is stale, and the patch returns 409.
</Warning>

### The first push normalises

A push rebuilds the workflow from your files, and fills in any defaults you left out. So the first push of an agent you built in the builder changes files you never edited:

```diff theme={null}
  # settings.yaml
- max_timeout_mins: 0
+ max_timeout_mins: 60
+ environment: browser

  # nodes/Done/settings.yaml
+ model: asteroid-balanced
```

Treat this like running a formatter. Push once, **pull again**, and commit what comes back. Nothing moves after that: every later pull and push leaves the files alone.

Do this before you start comparing hashes, or your first CI run reports a change that is only the defaults being filled in.

## Detect drift with the content hash

Every tree comes back with a `contentHash`: a SHA-256 of the whole directory. Compare it to find out whether the published agent still matches your checkout.

The hash always covers the whole workflow, even if you only asked for part of it. So a `contents=none` read gives you the same hash as a full one, for much less data.

```ts theme={null}
const { data: published } = await agentWorkflowsGetFilesByVersion({
  path: { agentId, workflowId },
  query: { contents: 'none' },
});

if (published!.contentHash !== committedHash) {
  throw new Error('the published agent does not match this commit');
}
```

`committedHash` is the `contentHash` your last `push` returned, stored in the lockfile. With this check in CI, a build fails if someone has changed the agent in the platform since you last pushed, and pulling shows you which files.

<Note>
  `contentHash` is missing if any agent file has no checksum yet. Treat a missing hash as "unknown" rather than "unchanged", and compare the files instead.
</Note>

## Put it in CI

* Commit the directory. It is the source of truth.
* Run `push` in the deploy job. Publishing is idempotent, so a deploy that changes nothing is safe to run.
* Your application calls `POST /agents/{agentId}/execute`, which runs the version in your repository.

Store the `contentHash` each push returns in the lockfile, next to the agent id. You cannot work the hash out yourself — the server computes it from its own copy of the workflow — so the only way to know whether the agent still matches your checkout is to compare against the hash a push gave you.

## Large agents

Some agents carry tens of thousands of agent files, so we recommend the following when you version one in git:

* **Do not ask for `contents=all`.** By default, structural files come back inline and agent files come back as references. `contents=all` downloads every file as well, including ones you already have.
* **Use the checksum to skip downloads.** Each agent file comes back with a checksum. If it matches the file on disk, you already have it and can move on. This is why `pull` above deletes only the files the server no longer lists, rather than clearing the directory — clearing it would throw away files it then has to download again.
* **Use `contents=none` when you only need `rev` or `contentHash`.** It returns paths, sizes and checksums, and no file content at all.
* **Ask for less.** `paths=` returns only the files you name. `maxFileBytes` and `maxTotalBytes` cap how much content comes back inline; anything larger comes back as a reference you fetch from its `downloadUrl`.

<CardGroup cols={2}>
  <Card title="Call a workflow" icon="code" href="/integrate/call-an-agent" horizontal>Execute an agent and read its result</Card>
  <Card title="Versions and publishing" icon="tag" href="/concepts/versions" horizontal>How saving, drafts and publishing decide which version runs</Card>
  <Card title="Nodes" icon="diagram-project" href="/concepts/nodes" horizontal>What goes in a node's `settings.yaml`</Card>
  <Card title="Production checklist" icon="check-check" href="/integrate/production-checklist" horizontal>Publishing, API keys, retries, and monitoring</Card>
</CardGroup>
