> ## 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 a workflow in git

> Keep a workflow'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.

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

So you keep a workflow 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 workflow match the repository and publishes the new version, so later API calls run it.

Two values track change: the **workflow id** says which workflow 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 workflow 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 workflow. Their content comes back inline, and writing one back rebuilds the graph.
* **Agent files** are the workflow'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 /workflows/{workflowId}/head/files` | The directory, with `rev` and `contentHash` |
| Read a published version | `GET /workflows/{workflowId}/versions/{versionId}/files` | The same shape, for one version |
| Write the directory back | `PATCH /workflows/{workflowId}/head/files` | The updated head |
| Publish the head | `POST /workflows/{workflowId}/head/publish` | `{ version, versionId }` |
| Run the published version | `POST /workflows/{workflowId}/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 /workflows/{workflowId}/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, workflowHeadGetFiles } 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(workflowId: 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 workflowHeadGetFiles({ path: { workflowId } });
    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(workflow_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).workflow_head_get_files(workflow_id=workflow_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 workflow 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 {
    workflowHeadGetFiles,
    workflowHeadPatchFiles,
    workflowHeadPublish,
    workflowVersionsGetFiles,
  } from 'asteroid-odyssey';

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

    const { data: head, error: readError } = await workflowHeadGetFiles({
      path: { workflowId },
      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 workflowHeadPatchFiles({
      path: { workflowId },
      body: { baseRev: head!.rev, writes, deletes },
    });
    if (patchError) throw new Error(`patch failed: ${JSON.stringify(patchError)}`);

    const { data: published, error: publishError } = await workflowHeadPublish({
      path: { workflowId },
    });
    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 workflowVersionsGetFiles({
      path: { workflowId, versionId: published!.versionId },
      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 a workflow 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 workflow 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 workflowVersionsGetFiles({
  path: { workflowId, versionId },
  query: { contents: 'none' },
});

if (published!.contentHash !== committedHash) {
  throw new Error('the published workflow 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 workflow 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 /workflows/{workflowId}/execute`, which runs the version in your repository.

Store the `contentHash` each push returns in the lockfile, next to the workflow 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 workflow still matches your checkout is to compare against the hash a push gave you.

## Large workflows

Some workflows 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-a-workflow" horizontal>Execute a workflow 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>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.