---
title: How to Run a CrewAI Agent on DigitalOcean Harness Runtime (public)
description: Package a CrewAI repository, declare kickoff inputs, connect a model and Action Gateway tools, and deploy your crew on DigitalOcean Harness Runtime.
product: Managed Agents
url: https://docs.digitalocean.com/products/managed-agents/agent-harness-runtime/how-to/run-crewai-agent/
last_updated: "2026-10-13"
---

> **For AI agents:** The documentation index is at [https://docs.digitalocean.com/llms.txt](https://docs.digitalocean.com/llms.txt). Markdown versions of pages use the same URL with `index.html.md` in place of the HTML page (for example, append `index.html.md` to the directory path instead of opening the HTML document).

# How to Run a CrewAI Agent on DigitalOcean Harness Runtime (public)

DigitalOcean Harness Runtime provides managed, hardware-isolated microVM sandboxes with built-in tools such as Chromium to run harnesses and execute arbitrary code. Rich lifecycle APIs preserve conversational history and working state across sessions, letting you pause, resume, and fork work to control costs and adapt to the nonlinear nature of agentic workflows. Scale complete agents such as Claude Code or use sandboxes independently for code execution, all through the same service. See [What You Can Build](https://docs.digitalocean.com/products/managed-agents/agent-harness-runtime/details/what-you-can-build/index.html.md) for example use cases.

DigitalOcean Harness Runtime runs your CrewAI application in an isolated sandbox. Unlike coding adapters, the CrewAI runtime runs your crew, rather than a general-purpose coding agent. The image includes only the mediator that runs your crew.

At session start, Harness Runtime does the following:

1. Clones your repository at an exact commit, named in the `crewai.framework` block of your spec, into the sandbox workspace.
2. Installs your Python dependencies from that checkout.
3. Imports your `Crew` object into `harness_crewai`, a small FastAPI mediator that the platform hosts for you.
4. Drives the mediator over HTTP/SSE. Each turn’s prompt text and structured inputs become one `Crew.kickoff(inputs=...)` call, and the results stream back to you.

Your application stays a standard CrewAI app. You do not need platform-specific imports or an SDK. To use the [Action Gateway](https://docs.digitalocean.com/products/managed-agents/action-gateway/index.html.md) tool catalog, the platform can pass your crew a standard MCP endpoint in an environment variable, which your crew attaches with CrewAI’s own MCP support. You can also create an Action Gateway session from your crew with the `pydo` client.

The CrewAI runtime uses the following components:

| Component | Value |
|---|---|
| Runtime adapter | `crewai` |
| Sandbox template | `crewai` |
| Mediator | `harness_crewai`, a FastAPI process that imports your crew and drives `kickoff()` |
| Python version in the image | Python 3.12 (Ubuntu 24.04) |
| Pinned CrewAI version | `crewai==1.15.11` |
| Session idle timeout | 600 seconds (10 minutes) between turns |

To run a CrewAI agent, you need a GitHub repository that contains your CrewAI app, with `crewai.json` at the repository root (or in a subdirectory you point to) and the exact commit SHA you want to run.

**Note**:

  CrewAI has no asynchronous human-in-the-loop. `Task(human_input=True)` is a blocking console prompt and hangs a headless sandbox, so crews that use it are rejected at preflight. If your workflow needs an approval step mid-run, this adapter does not support it yet.

## Set Up the Repository

A deployable CrewAI app needs a constructed `Crew` instance, a way for the platform to find it, and a dependency file. Unlike LangGraph, there is no separate compile step. We support both of the following layouts:

### requirements.txt

```text
my-crew/
|-- e2e_crew/
| |-- __init__.py
| |-- crew.py
| |-- agents.yaml
|   `-- tasks.yaml
|-- requirements.txt
`-- crewai.json
```

### pyproject.toml

```text
my-crew/
|-- e2e_crew/
| |-- __init__.py
| |-- crew.py
| |-- agents.yaml
|   `-- tasks.yaml
|-- pyproject.toml
`-- crewai.json
```

`agents.yaml` and `tasks.yaml` are optional CrewAI convention files. `crewai.json` (or `crewai.jsonc`, or a `[tool.crewai]` table in `pyproject.toml`) must resolve at the repository root, or in the directory you set with `crewai.framework.subdir` for monorepos. You can skip the manifest by setting `crewai.framework.object` in your spec.

### Create the crewai.json Manifest

Create `crewai.json` at the root of your repository. This file tells the `harness_crewai` mediator where your `Crew` object lives and, optionally, how a submitted prompt maps onto kickoff inputs.

Minimal example:

```json
{
  "crew": "e2e_crew.crew:crew"
}
```

With an input mapping:

```json
{
  "crew": "e2e_crew.crew:crew",
  "inputs": {
    "prompt_key": "topic",
    "defaults": {
      "year": 2026
    }
  }
}
```

The manifest supports the following fields:

- `crew` (required unless you set `crewai.framework.object` or a `[tool.crewai]` table): Points at your `Crew` instance as `module:attr`. The attribute must already be a constructed `Crew(...)`, not a factory function.
- `inputs.prompt_key` (optional): Kickoff-input name for the submitted prompt text. Without it, a plain-text turn is passed as `{"input": "<text>"}`.
- `inputs.defaults` (optional): Values merged under each turn’s inputs so tasks that expect fixed keys receive them on every run.

For each turn, inputs merge in this order: `defaults`, then the turn’s structured inputs, then `{prompt_key: <submitted text>}`. Later layers overwrite earlier keys, and non-empty text always wins for the prompt key.

**Note**:

  Declare the input mapping in your spec instead. The `crewai.inputs` block in the spec carries the same `prompt_key` and `defaults`, plus a typed schema that every turn is validated against. When the spec declares `crewai.inputs`, it takes precedence over the `inputs` block in `crewai.json` and is the only one checked at submit. If you use the spec block, keep `crewai.json` minimal with the `crew` key only. See [Declare Crew Inputs](#declare-crew-inputs).

**Warning**:

  Do not commit real secrets to a `.env` file in your repository. Put credentials under `secrets` and non-secret config under `env`, then read them with `os.environ` in your Python code.

### Declare Dependencies

At boot, we install the following dependencies from your checked-out repository:

- `requirements.txt`: `pip install --no-cache-dir -r requirements.txt`
- `pyproject.toml`: `pip install --no-cache-dir -e .`
- Neither: No install step. Your crew can only use packages preinstalled in the image.

If both `requirements.txt` and `pyproject.toml` exist, `requirements.txt` takes precedence and `pyproject.toml` is ignored. Pick one to avoid surprises.

For a typical LLM-backed crew, use a `requirements.txt` similar to:

```text
crewai>=0.80
crewai-tools[mcp]>=0.60
```

The `[mcp]` extra is only needed if you attach [Action Gateway](https://docs.digitalocean.com/products/managed-agents/action-gateway/index.html.md) tools through the session endpoint. If you use the PyDo client instead, add `pydo>=0.41.0`. See [Connect Action Gateway Tools](#connect-action-gateway-tools).

### Expose a Crew Instance

Point the `crew` key at a constructed `Crew` instance. Create or open `e2e_crew/crew.py`. Placeholders such as `{topic}` in agent and task text are filled from the turn’s kickoff inputs:

```python
import os

from crewai import Agent, Crew, Process, Task, LLM

llm = LLM(
    model=os.environ["HARNESS_INFERENCE_MODEL"],
    base_url=os.environ["HARNESS_INFERENCE_BASE_URL"],
    api_key=os.environ["HARNESS_INFERENCE_API_KEY"],
)

researcher = Agent(
    role="Researcher",
    goal="Find accurate, current information on {topic}",
    backstory="A meticulous researcher who cites sources.",
    llm=llm,
)

research_task = Task(
    description="Research {topic} for {audience} and list {max_facts} key facts.",
    expected_output="A concise, well-sourced summary.",
    agent=researcher,
)

crew = Crew(
    agents=[researcher],
    tasks=[research_task],
    process=Process.sequential,
)
```

The mediator sets `crew.stream = True` when your crew does not already set it, so token and tool events appear live in `doctl harness-runtime launch` and the Console. Do not set `human_input=True` on any `Task`. A crew with any such task fails mediator preflight, before your session can boot.

## Write the Environment Spec

Create a file such as `crewai-agent.yaml` that names your repository and commit. The clone target lives in the `crewai.framework` block:

```yaml
name: my-crewai-agent
agent: crewai
template: crewai
size: mars-2vcpu-4gb
persistent_workspace: true
egress:
  - inference.do-ai.run
crewai:
  framework:
    repo: https://github.com/<owner>/<repo>.git
    repo_sha: <exact-40-character-commit-sha>
    # subdir: services/research
    # object: research_crew.crew:crew
env:
  HARNESS_INFERENCE_BASE_URL: https://inference.do-ai.run/v1
  HARNESS_INFERENCE_MODEL: <your-model-name>
secrets:
  HARNESS_INFERENCE_API_KEY: ${HARNESS_INFERENCE_API_KEY}
```

Export the inference API key so `doctl` can expand `${HARNESS_INFERENCE_API_KEY}`:

```shell
export HARNESS_INFERENCE_API_KEY=<your-inference-api-key>
```

The spec uses the following fields:

- `agent` (required): Must be `crewai`.
- `template` (optional): `crewai` selects the CrewAI guest image and is the default for `agent: crewai`.
- `crewai.framework.repo` (required): Full `https://github.com/...` URL of your crew repository. HTTP URLs and non-GitHub hosts are rejected.
- `crewai.framework.repo_sha` (required): Exact 40-character commit SHA to check out. Branch and tag names are rejected because they move.
- `crewai.framework.subdir` (optional): Repository-relative directory that contains `crewai.json`, for monorepos. The value must not contain `..`.
- `crewai.framework.object` (optional): `module:attr` naming your crew. It bypasses `crewai.json` entirely and applies to the `crewai` adapter only.
- `crewai.inputs` (optional): The crew’s kickoff inputs: prompt key, defaults, and a typed schema. See [Declare Crew Inputs](#declare-crew-inputs).
- `size` (optional): A [sandbox size](https://docs.digitalocean.com/products/managed-agents/agent-harness-runtime/details/features/index.html.md#sandbox-sizes) slug (`mars-1vcpu-1gb`, `mars-2vcpu-2gb`, `mars-2vcpu-4gb`, `mars-4vcpu-8gb`, or `mars-16vcpu-32gb`).
- `env` and `secrets` (optional): Non-secret configuration under `env` and credentials under `secrets`. See [Connect a Model](#connect-a-model).
- `egress` (optional): Hosts the sandbox may reach, such as your model endpoint. When absent, egress is unrestricted.
- `tools` and `permissions` (optional): Action Gateway tools and their allow rules, if you use the session endpoint option. See [Connect Action Gateway Tools](#connect-action-gateway-tools).

**Note**:

  Always pin `crewai.framework.repo_sha` to an exact commit so every session boots the same code and your deployments stay reproducible.

**Warning**:

  Declare every credential under `secrets`. Credential-shaped values under `env` are rejected.

### Migrate from env.FRAMEWORK Fields

Older specs set the clone target as `env.FRAMEWORK_REPO`, `FRAMEWORK_REPO_SHA`, `FRAMEWORK_SUBDIR`, and `FRAMEWORK_OBJECT`. That spelling still works during the transition. The server lifts it into the `crewai.framework` block, the session boots unchanged, and create returns one deprecation warning per key. `doctl harness-runtime validate` accepts it too.

Declaring a value both ways with different contents is rejected with a 400 error, for example when `env.FRAMEWORK_REPO_SHA` and `crewai.framework.repo_sha` differ. Move to the block when you can, because the `env` will be removed in the future. The same applies to `env.FRAMEWORK_INPUT_PROMPT_KEY` and `FRAMEWORK_INPUT_DEFAULTS`, which `crewai.inputs.prompt_key` and `crewai.inputs.defaults` replace.

A top-level `framework:` or `inputs:` block is rejected. Nest both under `crewai:`. The Kubernetes-style `apiVersion`, `kind`, and `spec` envelope still parses but is deprecated.

## Declare Crew Inputs

A CrewAI turn is a `Crew.kickoff(inputs={...})` call. The optional `crewai.inputs` block in your spec declares what that dictionary may contain, so the platform can fill it from a turn and reject a bad turn before it reaches your crew.

```yaml
crewai:
  inputs:
    prompt_key: topic
    defaults:
      audience: engineers
      year: 2026
      max_facts: 3
      min_confidence: 0.8
      include_sources: true
    schema:
      topic:           {type: string,  required: true}
      audience:        {type: string}
      year:            {type: integer, required: true}
      max_facts:       {type: integer}
      min_confidence:  {type: number}
      include_sources: {type: boolean}
```

The block supports the following fields:

- `prompt_key`: The input that a turn’s free text fills. Required when `schema` is set, and it must name a declared `string` input.
- `defaults`: Values merged under every turn. With a schema, each default must be declared and match its type. Values are strings, numbers, or `true` and `false` values. Lists, objects, and null are not accepted.
- `schema.<name>.type`: One of `string`, `integer`, `number`, or `boolean`.
- `schema.<name>.required`: The turn is rejected unless the input ends up set by the turn’s inputs, a default, or, for the prompt key, the turn’s text.

Input names are CrewAI placeholder names: letters, digits, underscores, and hyphens, not starting with a digit or hyphen, with a maximum of 64 bytes. The schema holds at most 64 inputs.

### Understand Turn Validation

With a schema declared, every `SendInput` turn, whether from the Console, `doctl`, a trigger, or the API, is validated at submit. A turn that fails gets a 400 error that names the input, and no run is created.

| Turn | Result |
|---|---|
| Text `how coral reefs form`, no inputs | Runs. `topic` comes from the text and everything else from `defaults`. |
| Inputs `{"topic": "rust ownership", "audience": "students", "max_facts": 2}` | Runs, with those three values overriding the defaults. |
| Inputs `{"topc": "x"}` | `400 invalid inputs["topc"]: not declared in the agent's inputs.schema (did you mean "topic"?)` |
| Inputs `{"topic": "x", "year": "2026"}` | `400 invalid inputs["year"]: declared integer, got string` |
| Inputs `{"topic": "x", "max_facts": 2.5}` | `400 invalid inputs["max_facts"]: declared integer, got 2.5` |
| A required input with no default that the turn does not set | `400 invalid inputs["year"]: required by the agent's inputs.schema; send it in inputs` |

Integers must have an absolute value of at most 9007199254740991 (2^53 minus 1). Declare larger values as `string`. Without a schema, `prompt_key` and `defaults` still apply, and turns are only held to a general shape: a flat object of strings (up to 16 KiB each), finite numbers, and `true` and `false` values, with at most 64 keys.

### Fix Create-Time Errors

Mistakes in the declaration itself fail at session or config create. For example:

- `crewai.inputs.prompt_key is required with crewai.inputs.schema`: A turn’s text must fill a declared input.
- `crewai.inputs.schema.tags.type "list" must be one of string, integer, number, boolean`: The type is not supported.
- `crewai.inputs.defaults.yr is not declared in crewai.inputs.schema`: Every default needs a matching schema entry.
- `inputs is only read by the crewai adapter`: The block appears under a different agent.
- Declaring `crewai.inputs` together with `env.FRAMEWORK_INPUT_PROMPT_KEY` or `FRAMEWORK_INPUT_DEFAULTS` is rejected. Declare the mapping once, in `crewai.inputs`.

**Note**:

  A value sent in a turn’s `inputs` currently reaches your crew as a float, so `2` renders as `2.0` in task text. This happens because the turn is carried as JSON numbers end to end. Defaults declared in the spec are unaffected, so `year: 2026` renders as `2026`. Until a fix ships, declare an input as `string` if its exact text matters.

## Connect a Model

Your crew owns model configuration. We do not choose or inject a model client for you. Declare your settings in the spec, then read them from `os.environ` in Python.

Use the following environment variables:

- `HARNESS_INFERENCE_API_KEY`: API key for your inference endpoint. Declare it under `secrets`.
- `HARNESS_INFERENCE_BASE_URL`: OpenAI-compatible base URL, including the `/v1` suffix, for example `https://inference.do-ai.run/v1`.
- `HARNESS_INFERENCE_MODEL`: Model name to request from that endpoint.

CrewAI’s `LLM` class takes `model`, `base_url`, and `api_key` directly. Create the client once at module scope so every agent reuses it:

```python
import os

from crewai import LLM

llm = LLM(
    model=os.environ["HARNESS_INFERENCE_MODEL"],
    base_url=os.environ["HARNESS_INFERENCE_BASE_URL"],
    api_key=os.environ["HARNESS_INFERENCE_API_KEY"],
    temperature=0,
)
```

We recommend the following:

- Never hardcode keys in your repository, and keep them out of `env`. Values in `env` are readable by anyone who can inspect the session.
- Read configuration with `os.environ["KEY"]` rather than a silent default, so missing configuration fails at import time.
- Other variable names work too. `HARNESS_INFERENCE_*` is the platform convention.

## Access a Private Repository

If `crewai.framework.repo` is private, declare `GITHUB_TOKEN` under `secrets`. The token is sent as an HTTP `Authorization` header on the fetch. It is never written into the repository URL or a git remote, and only the pinned commit is fetched (`--depth 1`).

```yaml
crewai:
  framework:
    repo: https://github.com/<owner>/<private-repo>.git
    repo_sha: <exact-40-character-commit-sha>
secrets:
  GITHUB_TOKEN:
    source: oauth
    provider: github
    actor: <actor-id>
```

Create the GitHub connection first with `doctl harness-runtime connections create --actor <actor-id>`, and use the same actor ID in the spec. You can also declare a personal access token instead. A fine-grained token with **Contents: Read** on the repository is enough. Classic tokens with the `repo` scope and GitHub App installation tokens also work. See [Connect GitHub](https://docs.digitalocean.com/products/managed-agents/agent-harness-runtime/how-to/connect-github/index.html.md).

## Connect Action Gateway Tools

[Action Gateway](https://docs.digitalocean.com/products/managed-agents/action-gateway/index.html.md) gives your crew governed access to a catalog of hosted tools, such as web search, web fetch, and code execution. Coding adapters attach these tools automatically. CrewAI has no automatic MCP attach, so your crew code wires the tools in itself. You can choose between the following two options:

| | Session endpoint | PyDo client |
|---|---|---|
| How it works | The platform mints a session-pinned Action Gateway endpoint and passes its URL to your crew in an environment variable. Your crew attaches it with CrewAI’s MCP support. | Your crew creates its own Action Gateway session with `pydo` and wraps the discovered tools as CrewAI `BaseTool`s. |
| Credential in the sandbox | None. The endpoint URL is pinned to your session and needs no token. | A DigitalOcean API token (`DIGITALOCEAN_TOKEN`) under `secrets`. |
| Where tool policy lives | In the spec, under `tools` and `permissions`. | In your crew code, in the `session.create(permissions=...)` call. |
| Extra egress | None for Action Gateway. | `api.digitalocean.com` and `actions.do-ai.run`. |
| Dependency | `crewai-tools[mcp]` | `pydo>=0.41.0` |

Choose the session endpoint if you want the sandbox to hold no Action Gateway credential. Choose the PyDo client if you prefer to keep policy in code and accept that the sandbox holds a token scoped for Action Gateway.

### Use the Session Endpoint

#### Select the Tools in Your Spec

Add the tools to `tools`, preload the ones your crew calls by name, and allow them under `permissions`:

```yaml
tools:
  - do.actions:
      tools: [exa_web_search]
      preload_tools: [exa_web_search]
permissions:
  rules:
    - tool: do.actions/exa_web_search
      action: allow
```

`tools` limits what the session may reach. `preload_tools` is what makes a tool appear by name to your crew. Without it, your crew sees only the gateway’s three meta tools (`action_search`, `action_invoke`, and `action_code`) and never calls `exa_web_search` directly. Every `preload_tools` entry must also appear in `tools`. Keep the list short, because each preloaded schema sits in the model’s context on every turn.

Tool slugs are provider-qualified, for example `exa_web_search` and not `web_search`. A bare slug is rejected at create. See the [environment spec reference](https://docs.digitalocean.com/products/managed-agents/agent-harness-runtime/reference/environment-spec/index.html.md) for the full `tools` syntax.

#### Read the Endpoint from the Environment

Every MCP server your spec attaches gets its own environment variable in the sandbox, which holds that server’s endpoint URL:

| In your spec | Environment variable |
|---|---|
| `- do.actions` | `HARNESS_MCP_URL_DO_ACTIONS` |
| Inline server named `internal.jira` | `HARNESS_MCP_URL_INTERNAL_JIRA` |

The name is `HARNESS_MCP_URL_` followed by the server name in upper case, with `.` and `-` turned into `_`. The value is only the URL. The Action Gateway endpoint is pinned to your session and needs no credential. The prefix is reserved, so `env` cannot set these variables.

#### Attach the Tools in Your Crew Code

Add CrewAI’s tools package with its MCP extra to your dependencies (`crewai-tools[mcp]`), then pass the URL to `MCPServerAdapter`. Open `e2e_crew/crew.py` and add the following:

```python
import os

from crewai import Agent
from crewai_tools import MCPServerAdapter

ag_url = os.environ.get("HARNESS_MCP_URL_DO_ACTIONS")
ag_tools = (
    MCPServerAdapter(
        {"url": ag_url, "transport": "streamable-http"},
        "exa_web_search",
    ).tools
    if ag_url
    else []
)

researcher = Agent(
    role="Researcher",
    goal="Gather 2-3 crisp facts about {topic}",
    backstory="You are a concise researcher.",
    llm=llm,
    tools=ag_tools,
)
```

Tools keep the names the gateway advertises. The tool names after the server parameters narrow the result. Leave them out to get every tool the gateway lists. The empty list when `ag_url` is unset lets the same code run locally, outside Managed Agents. The adapter connects when your module is imported at session boot and waits up to 30 seconds by default (`connect_timeout`).

If you use `@CrewBase`, set `mcp_server_params` on the class and pick tools per agent with `get_mcp_tools()`. CrewAI opens the connection when it first needs the tools and closes it after each kickoff:

```python
ag_url = os.environ.get("HARNESS_MCP_URL_DO_ACTIONS")

@CrewBase
class ResearchCrew:
    mcp_server_params = (
        {"url": ag_url, "transport": "streamable-http"} if ag_url else None
    )

    @agent
    def researcher(self) -> Agent:
        return Agent(
            config=self.agents_config["researcher"],
            tools=self.get_mcp_tools("exa_web_search"),
        )
```

**Warning**:

  Do not use `Agent(mcps=[MCPServerHTTP(url=...)])` for gateway tools. CrewAI prefixes every tool name with the endpoint’s host and path, then cuts it to 64 characters. With a session endpoint, the model sees only names that end in a hash. Use `MCPServerAdapter` or `@CrewBase` as shown.

#### Enforcement Limits for Crews

A crew runs your code directly. No platform permission layer sits in front of it, and nothing in it can show an approval prompt. Only what is enforced outside the sandbox applies:

| In your spec | Enforced |
|---|---|
| `egress` | Yes, at the network layer. |
| `do.actions/<tool>` rules with `allow` or `deny` | Yes, by Action Gateway. |
| `ask` on a `do.actions` tool (a rule or `default: ask`) | No. The call goes through without asking. Use `allow` or `deny`. |
| Any other rule (`bash`, `file.*`, `web.fetch`, or an inline `<server>/<tool>`) | No. By defaults, a strict `deny` fails session create because it cannot be enforced. |
| An inline server’s `tools` selection | No. Your crew sees the server’s full tool list. |

Session create warns about each “No” in this table. If your crew needs one of them, enforce it in your code.

#### Attach Your Own MCP Servers

An inline server declared under `tools` gets its own `HARNESS_MCP_URL_<NAME>` variable. Its credential is not in the URL. Read it from the environment variable named after its `auth.secret` slot and send it yourself, for example `{"url": ..., "transport": "streamable-http", "headers": {"Authorization": "Bearer " + token}}`. List the server’s host under `egress`.

### Use the PyDo Client

With this option, your crew mints its own Action Gateway session and the platform does not attach a tool endpoint. Because the crew holds a real token, treat `DIGITALOCEAN_TOKEN` like any other credential, and scope it as narrowly as your token flow allows.

#### Declare the Token and Egress

Provide a [DigitalOcean personal access token](https://docs.digitalocean.com/reference/api/create-personal-access-token/index.html.md) authorized for Action Gateway under `secrets` as `DIGITALOCEAN_TOKEN`. Never put it under `env`, because `env` values are readable by anyone who can inspect the session. This flow talks to the public DigitalOcean API and Action Gateway hosts directly, so allow egress to both. Do not add `tools` or `permissions.rules` entries for Action Gateway. Policy lives in your code:

```yaml
secrets:
  HARNESS_INFERENCE_API_KEY: ${HARNESS_INFERENCE_API_KEY}
  DIGITALOCEAN_TOKEN: ${DIGITALOCEAN_TOKEN}
egress:
  - inference.do-ai.run
  - api.digitalocean.com
  - actions.do-ai.run
```

Export the token so `doctl` can expand `${DIGITALOCEAN_TOKEN}`, and add `pydo>=0.41.0` to your dependency file:

```shell
export DIGITALOCEAN_TOKEN=<your-digitalocean-api-token>
```

#### Create the Session and Wrap Tools in Your Crew Code

Start a session that names the tools your crew needs, then wrap each discovered tool as a CrewAI `BaseTool`. Call `session.create` once at import time, not per kickoff, so each turn does not create a new Action Gateway session:

```python
import os

def load_action_gateway_tools() -> list:
    token = os.environ.get("DIGITALOCEAN_TOKEN", "")
    if not token:
        return []

    requested = ["exa_web_search"]

    from pydo.action_gateway import ActionGatewayClient

    client = ActionGatewayClient(token=token)
    session = client.session.create(
        actor_id=os.environ.get("SESSION_ID", "my-crew"),
        tools=requested,
        config={"preloadTools": requested},
        permissions={
            "default_action": "deny",
            "rules": [
                {"tool": name, "action": "allow"} for name in requested
            ],
        },
    )

    from crewai.tools import BaseTool
    from pydantic import create_model

    tools = []
    for tool_def in session.tools.list(include_all=True):
        name = tool_def.get("name")
        if name not in requested:
            continue
        schema = tool_def.get("inputSchema") or {}
        props = schema.get("properties", {})
        required = set(schema.get("required", []))
        fields = {
            p: (str, ... if p in required else None) for p in props
        }
        args_model = (
            create_model(f"{name}_Args", **fields) if fields else None
        )

        class _GatewayTool(BaseTool):
            def _run(self, **kwargs):
                return session.tools.call(name, kwargs)

        kwargs = {
            "name": name,
            "description": tool_def.get("description", ""),
        }
        if args_model is not None:
            kwargs["args_schema"] = args_model
        tools.append(_GatewayTool(**kwargs))

    return tools

# Attach when you construct agents:
# researcher = Agent(..., tools=load_action_gateway_tools())
```

`session.tools.call(name, arguments)` invokes one catalog tool and returns its output. For tool discovery, parallel calls, and model tool-call loops, see the `pydo/examples/gateway` examples in the [PyDo](https://github.com/digitalocean/pydo) repository.

**Note**:

  Your `session.create(permissions=...)` call is the only Action Gateway policy gate in this option. There is no platform-side approval prompt, so declare an explicit `allow` rule for every tool your crew calls. Tool-call events show the real tool name, such as `exa_web_search`, rather than a generic gateway envelope.

## Deploy and Run the Crew

Check the spec client-side:

```shell
doctl harness-runtime validate --spec ./crewai-agent.yaml
```

Create a session from the spec:

```shell
doctl harness-runtime create --spec ./crewai-agent.yaml
```

Cold start clones your repository, installs dependencies, and runs preflight checks on your crew before the session is ready. Note the session ID, then attach to the session in your terminal:

```shell
doctl harness-runtime launch <session-id-or-name>
```

Detach with `Ctrl+D`. The session stays alive until you pause or remove it. To send one headless turn, run:

```shell
doctl harness-runtime prompt <session-id-or-name> "how coral reefs form"
```

To replay the event stream, run:

```shell
doctl harness-runtime logs <session-id-or-name>
```

### Send Structured Inputs

`launch` and `prompt` send prompt text only, which fills `prompt_key`. To set other inputs for a turn, call the session’s input endpoint:

```shell
curl -sS -X POST "https://api.digitalocean.com/v2/agents/sessions/<session-id>/input" \
  -H "Authorization: Bearer $DIGITALOCEAN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"text": "", "inputs": {"topic": "rust ownership", "audience": "students", "max_facts": 2}}'
```

The request returns `{"run_id": "..."}` right away, and the run’s output arrives on the event stream.

### Read the Event Stream

Each turn produces an ordered stream of events mapped from CrewAI’s event bus:

| Event | Meaning |
|---|---|
| `run.started` | Your input started a run, which is a fresh `Crew.kickoff()` call. The `data.agent` field is a JSON string of the merged kickoff inputs, including the prompt text under your `prompt_key`. |
| `token` | Reply text streamed live as your crew’s agents produce it. |
| `run.tool_call_started` / `run.tool_call_completed` | A CrewAI tool call started or finished, sourced from CrewAI’s own event bus. |
| `run.completed` | The crew finished. Token usage and the model are reported here. |
| `run.failed` | The run ended with an error. The message carries the reason. |

Each turn is an independent `Crew.kickoff()` call against the same long-running mediator process. Nothing carries conversation context from one turn to the next unless your crew configures memory itself.

Remove the session when you are finished:

```shell
doctl harness-runtime remove <session-id-or-name>
```

Session names must be unique within your team. Remove the previous session or change `name` before reusing a name. The CrewAI guest idle timeout is 10 minutes between turns. Send input within that window, or start a new session after an idle timeout ends the run.

## Check Your Setup Before Deploying

Before you deploy, confirm the following:

- `crewai.json` exists at your repository root or in `crewai.framework.subdir`, unless you set `crewai.framework.object`.
- The `crew` key points at an already-constructed `Crew` instance in `module:attr` form.
- `requirements.txt` or `pyproject.toml` lists every package your crew imports, and the crew imports cleanly against them. Mediator preflight rejects the session if imports fail.
- No `Task` sets `human_input=True`.
- Your commit is pushed to GitHub, and `crewai.framework.repo` and `repo_sha` name it exactly.
- `agent: crewai` is set, plus `template: crewai` if you set a template.
- If your crew uses placeholders beyond the prompt, `crewai.inputs` declares them, with defaults for any it needs on every turn, and `prompt_key` names the input that the prompt text fills.
- Model settings are in `env`, the API key is under `secrets`, and your code passes them to `crewai.LLM(...)`.
- If the repository is private, `GITHUB_TOKEN` is declared under `secrets`.
- If you use Action Gateway through the session endpoint, every tool is in `tools` and `preload_tools` with an `allow` rule, not `ask`, and your crew reads `HARNESS_MCP_URL_DO_ACTIONS`.
- If you use Action Gateway through the PyDo client, `DIGITALOCEAN_TOKEN` is under `secrets`, `api.digitalocean.com` and `actions.do-ai.run` are in `egress`, and every tool your crew calls has an `allow` rule in `session.create`.
- `doctl harness-runtime validate` passes, and no secrets are committed to the repository.

## Troubleshoot CrewAI Sessions

Use this table to diagnose common CrewAI deployment errors:

| Symptom | Likely cause | What to do |
|---|---|---|
| `crewai.framework is required for adapter "crewai"` | No clone target | Add `crewai.framework.repo` and `crewai.framework.repo_sha`. |
| `crewai.framework.repo_sha "main" must be a full 40-character commit SHA` | A branch or tag name | Use the exact commit SHA. |
| `env.FRAMEWORK_REPO is deprecated` warning | Old `env` spelling | The spec works as is. Move the values into `crewai.framework` when you can. |
| `both set with different values; declare it once, under crewai.framework` | The same field in `env.FRAMEWORK_*` and `crewai.framework` | Remove the `env` copy. |
| `field "framework" is not a top-level flat field` | Spec written before `framework` and `inputs` moved under `crewai:` | Nest both blocks under `crewai:`. |
| Session fails to boot; no crew running | `crewai.json` missing at the resolved directory | Add `crewai.json`, set `crewai.framework.subdir`, or set `crewai.framework.object`. |
| `crewai.framework.repo` rejected | URL is not an HTTPS `github.com` address | Use a full `https://github.com/<owner>/<repo>.git` URL. |
| Clone or fetch fails | Private repository, bad SHA, or invalid token | Check the URL and commit, and set a valid `GITHUB_TOKEN`. |
| Credential rejected under `env` | API key or similar value placed under `env` | Move it to `secrets`. |
| Size rejected | Unsupported `size` value | Use a current sandbox size slug: `mars-1vcpu-1gb`, `mars-2vcpu-2gb`, `mars-2vcpu-4gb`, `mars-4vcpu-8gb`, or `mars-16vcpu-32gb`. |
| `run failed: Template variable 'topic' not found` | Nothing maps the prompt text onto the placeholder | Set `crewai.inputs.prompt_key`, or a default for that input. |
| `400 invalid inputs["..."]` on a turn | The turn breaks the declared `crewai.inputs.schema` | Fix the named input: declared name, type, or a missing required value. |
| An integer input shows as `2.0` in task text | Known limit for values sent in a turn | Declare it as `string`, or rely on a default. |
| Crew calls only `action_search`, `action_invoke`, and `action_code` | The tool is selected but not preloaded | Add it to `preload_tools`. |
| `permission rule tool ... must use its provider-qualified slug` | A bare tool name | Use the provider-qualified slug, for example `exa_web_search`. |
| Crew has no Action Gateway tools and `HARNESS_MCP_URL_DO_ACTIONS` is unset | The spec attaches no `do.actions` server | Add `- do.actions` under `tools`. |
| Action Gateway session create failed, or no tools available (PyDo client) | `DIGITALOCEAN_TOKEN` missing or invalid, or the sandbox cannot reach the DigitalOcean API | Confirm the token is under `secrets`, and that `api.digitalocean.com` and `actions.do-ai.run` are in the sandbox egress allowlist. |
| Tool names are long and end in a hash | Tools attached with `Agent(mcps=[MCPServerHTTP(...)])` | Use `MCPServerAdapter` or `@CrewBase`. |
| A tool with an `ask` rule runs without approval | A crew has no approval surface | Use `allow` or `deny`. |
| Preflight rejects the crew | `Task.human_input=True` present | Remove it. Asynchronous human-in-the-loop is not supported for this adapter. |
| Model call fails or hits the wrong endpoint | `HARNESS_INFERENCE_*` not passed into `LLM(...)` | Pass `model=`, `base_url=`, and `api_key=` explicitly. |
| `ModuleNotFoundError` at run time | Dependency missing from your dependency file | Add the package, push a new commit, and update `crewai.framework.repo_sha`. |
| Unexpected dependency install | Both dependency files present | Consolidate into one file. `requirements.txt` wins over `pyproject.toml`. |
| Session create fails on name | Session name already in use | Remove the old session or change `name`. |
| Run is terminal; create a new one | The 10-minute idle timeout ended the run | Start a new session and send input within about 10 minutes of each turn. |

## Next Steps

- Learn how Action Gateway selects and governs tools in the [environment spec reference](https://docs.digitalocean.com/products/managed-agents/agent-harness-runtime/reference/environment-spec/index.html.md).
- Read the [CrewAI documentation](https://docs.crewai.com) for crews, tasks, and [MCP servers as tools](https://docs.crewai.com/en/mcp/overview).