Skip to content
Gauge

ax-check.com

AirOps

airops.com

ax-check.com

Checked 21 September 2026

C

60/100

Agents need your login

Docs and quickstart work, but plan prices stay hidden

Solo and Pro tiers say "Start for free" instead of dollar prices; only usage limits and overage rate ($0.025/task) are stated.

4 fixes to start with

Concrete changes, backed by what we found.

Onboarding

Give agents a clear login handoff and a way to resume setup.

Agents cannot progress through onboarding without human help. Provide an agent-accessible path through authentication and setup.

Docs

Publish prices alongside the sales contact.

Solo and Pro show 'Start for free' with no dollar prices; only Enterprise is contact-sales.

Docs

Return Markdown when clients request text/markdown.

Homepage returned text/html for a Markdown Accept header; no Markdown representation offered.

Docs

Add links to your product documentation and quickstart in /llms.txt.

https://airops.com/llms.txt returned the HTML homepage, not a documentation index.

Recorded coding sessions

Same task, 3 models, no airops.com account. Recorded 21 September 2026 on Pi.

3 recorded · 10m 54s

10m 54s · recorded

Session findings

Select a finding to see it in context.

piv0.84.2
modelQwen 3.8 Maxcwd/sandbox/repo
Help me build a simple example using AirOps. Tell me how pricing works, and briefly tell me whether this product will be easy for you to manage. Let me know if you get blocked. If this product has no developer workflow you can act on, say so plainly and stop. Stay light: use the hosted product through its SDK or API. Do not start local service stacks or wait for long-running commands; if the quickstart requires either, say so plainly and stop.
Thinking…

I need to figure out what "AirOps" is. Let me check the environment / repository first.

Let me look at the current working directory.

There's ambiguity: "AirOps" could be a few things. Let me investigate the sandbox repo for context.

Also important: the instruction says "If this product has no developer workflow you can act on, say so plainly and stop." and "Stay light: use the hosted product through its SDK or API."

Let me check the repo and network access.

$ pwd; ls -la; echo "---GIT---"; git log --oneline -5 2>/dev/null; echo "---FILES---"; find . -maxdepth 2 -not -path './.git/*' | head -50{"content":[{"text":"/sandbox/repo\ntotal 16\ndrwxr-xr-x 3 root root 4096 Sep 18 08:00 .\ndrwxr-xr-x 7 root root 4096 Sep 21 14:57 ..\ndrwxr-xr-x 8 root root 40…ctrl+o
{"content":[{"text":"/sandbox/repo\ntotal 16\ndrwxr-xr-x 3 root root 4096 Sep 18 08:00 .\ndrwxr-xr-x 7 root root 4096 Sep 21 14:57 ..\ndrwxr-xr-x 8 root root 4096 Sep 18 08:00 .git\n-rw-r--r-- 1 root root  408 Sep 18 08:00 .gitignore\n---GIT---\n9ebf5fb Initial workspace\n---FILES---\n.\n./.gitignore\n./.git\n","type":"text"}]}
$ env | grep -i -E 'air|ops|key|token|api' | sed 's/=.*/=<redacted>/' | head -40; echo "---PI---"; env | grep -i '^PI_' | sed 's/\(KEY\|TOKEN\)=.*/\1=<redacted>/'{"content":[{"text":"PI_GATEWAY_API_KEY=<redacted>\nGIT_CONFIG_KEY_0=<redacted>\n---PI---\nPI_CODING_AGENT=true\nPI_REASONING_LEVEL=medium\nPI_SESSION_FILE=/san…ctrl+o
{"content":[{"text":"PI_GATEWAY_API_KEY=<redacted>\nGIT_CONFIG_KEY_0=<redacted>\n---PI---\nPI_CODING_AGENT=true\nPI_REASONING_LEVEL=medium\nPI_SESSION_FILE=/sandbox/.gauge/pi/sessions/2026-09-21T14-57-05-021Z_01a0c478-79fd-78d1-9987-7cd46731f8e8.jsonl\nPI_PROVIDER=gauge-fireworks\nPI_MODEL=accounts/fireworks/models/qwen3p8-max\nPI_SESSION_ID=01a0c478-79fd-78d1-9987-7cd46731f8e8\nPI_GATEWAY_API_KEY=<redacted>\n","type":"text"}]}
Thinking…

The repo is empty. There are no AirOps credentials. Let me check network access and whether "AirOps" exists as a known product/SDK. Let me try npm and PyPI lookups, and network reachability.

Let me check the .gitignore for hints too.

$ cat .gitignore{"content":[{"text":"# Keep generated and vendored files out of source diffs.\n\n# Logs / OS cruft\n*.log\n.DS_Store\n\n# JavaScript / TypeScript\nnode_modules/…ctrl+o
{"content":[{"text":"# Keep generated and vendored files out of source diffs.\n\n# Logs / OS cruft\n*.log\n.DS_Store\n\n# JavaScript / TypeScript\nnode_modules/\n.next/\ndist/\nbuild/\ncoverage/\n\n# Python\n.venv/\nvenv/\n__pycache__/\n*.py[cod]\n.pytest_cache/\n*.egg-info/\n\n# Rust\ntarget/\n\n# Go / PHP / Ruby vendored deps\nvendor/\n.bundle/\n\n# Java / Kotlin / Scala\n.gradle/\n\n# .NET\nbin/\nobj/\n\n# Elixir\n_build/\ndeps/\n\n# Dart / Flutter\n.dart_tool/\n","type":"text"}]}
$ echo "--- npm search ---"; timeout 25 npm search airops --json 2>&1 | head -30; echo "--- npm view airops ---"; timeout 25 npm view airops 2>&1 | head -20; echo "--- pypi ---"; timeout 25 curl -sS htt…{"content":[{"text":"--- npm search ---\n[\n\n{\"name\":\"@airops/airops-js\",\"keywords\":[],\"version\":\"2.3.0\",\"description\":\"JavaScript SDK for interac…ctrl+o
echo "--- npm search ---"; timeout 25 npm search airops --json 2>&1 | head -30; echo "--- npm view airops ---"; timeout 25 npm view airops 2>&1 | head -20; echo "--- pypi ---"; timeout 25 curl -sS https://pypi.org/pypi/airops/json 2>&1 | head -c 500

{"content":[{"text":"--- npm search ---\n[\n\n{\"name\":\"@airops/airops-js\",\"keywords\":[],\"version\":\"2.3.0\",\"description\":\"JavaScript SDK for interacting with AirOps API\",\"sanitized_name\":\"@airops/airops-js\",\"publisher\":{\"email\":\"nico.tinte@gmail.com\",\"username\":\"tintef\"},\"maintainers\":[{\"email\":\"gobernabe@gmail.com\",\"username\":\"bernabe9\"},{\"email\":\"nico.tinte@gmail.com\",\"username\":\"tintef\"},{\"email\":\"schubert@airops.com\",\"username\":\"schubert\"}],\"license\":\"MIT\",\"date\":\"2024-08-28T19:13:55.978Z\",\"links\":{\"homepage\":\"https://github.com/airopshq/airops-js#readme\",\"repository\":\"git+https://github.com/airopshq/airops-js.git\",\"bugs\":\"https://github.com/airopshq/airops-js/issues\",\"npm\":\"https://www.npmjs.com/package/@airops/airops-js\"}}\n\n,\n\n{\"name\":\"@pipedream/airops\",\"keywords\":[\"pipedream\",\"airops\"],\"version\":\"0.1.0\",\"description\":\"Pipedream AirOps Components\",\"sanitized_name\":\"@pipedream/airops\",\"publisher\":{\"email\":\"npm@casret.com\",\"username\":\"casret\"},\"maintainers\":[{\"email\":\"npm@casret.com\",\"username\":\"casret\"},{\"email\":\"dylan.sather@workday.com\",\"username\":\"dylburger-workday\"},{\"email\":\"adolfo.castellon@workday.com\",\"username\":\"adolfo-wd\"},{\"email\":\"adolfo@pipedream.com\",\"username\":\"adolfo-pd\"},{\"email\":\"alan@pipedream.com\",\"username\":\"alan-pipedream\"}],\"date\":\"2023-10-24T15:13:13.873Z\",\"links\":{\"homepage\":\"https://pipedream.com/apps/airops\",\"npm\":\"https://www.npmjs.com/package/@pipedream/airops\"}}\n]\n\n--- npm view airops ---\nnpm error code E404\nnpm error 404 Not Found - GET https://registry.npmjs.org/airops - Not found\nnpm error 404\nnpm error 404  'airops@*' is not in this registry.\nnpm error 404\nnpm error 404 Note that you can also install from a\nnpm error 404 tarball, folder, http url, or git url.\nnpm error A complete log of this run can be found in: /sandbox/.npm/_logs/2026-09-21T14_57_15_488Z-debug-0.log\n--- pypi ---\n{\"info\":{\"author\":null,\"author_email\":\"AirOps <support@airops.com>\",\"bugtrack_url\":null,\"classifiers\":[\"Development Status :: 3 - Alpha\",\"Intended Audience :: Developers\",\"License :: OSI Approved :: MIT License\",\"Programming Language :: Python :: 3\",\"Programming Language :: Python :: 3.13\",\"Topic :: Software Development :: Libraries :: Python Modules\"],\"description\":\"# AirOps Python SDK\\n\\nBuild custom tools that integrate with AirOps' Steps API.\\n\\n## Installation\\n\\nRequires Python 3.13+ and [","type":"text"}]}
Thinking…

Good — AirOps is a real product (airops.com) with a JS SDK (@airops/airops-js) and a Python SDK (airops on PyPI). Network access works. Let me look at the SDK docs and pricing.

Key question: do I have credentials? No AIROPS_API_KEY in env. So the practical blocker is credentials. But the task says "build a simple example using AirOps", "tell me how pricing works", "briefly tell me whether this product will be easy for you to manage", "Let me know if you get blocked."

Let me investigate:

  1. The SDK API surface (Python SDK description from PyPI, npm package README).
  2. Pricing from airops.com.
  3. Whether there's a public/self-serve signup with API keys, or whether the API requires a paid workspace.

Let me fetch these.

$ cd /tmp && timeout 60 curl -sS https://pypi.org/pypi/airops/json -o airops_pypi.json && python3 -c "{"content":[{"text":"VERSION: 0.2.7\nHOME: None {'Documentation': 'https://docs.airops.com', 'Homepage': 'https://airops.com', 'Repository': 'https://github.com…ctrl+o
cd /tmp && timeout 60 curl -sS https://pypi.org/pypi/airops/json -o airops_pypi.json && python3 -c "
import json
d=json.load(open('airops_pypi.json'))
i=d['info']
print('VERSION:', i['version'])
print('HOME:', i.get('home_page'), i.get('project_urls'))
print('REQUIRES:', i.get('requires_python'), i.get('requires_dist'))
print('SUMMARY:', i.get('summary'))
print('=== DESCRIPTION ===')
print(i['description'][:8000])
"

{"content":[{"text":"VERSION: 0.2.7\nHOME: None {'Documentation': 'https://docs.airops.com', 'Homepage': 'https://airops.com', 'Repository': 'https://github.com/airops/airops-sdk'}\nREQUIRES: >=3.13 ['fastapi>=0.115.0', 'fastui>=0.7.0', 'httpx>=0.28.0', 'jinja2>=3.0.0', 'pydantic>=2.10.0', 'python-dotenv>=1.0.0', 'python-multipart>=0.0.9', 'rich>=13.0.0', 'sentry-sdk>=2.56.0', 'tenacity>=9.0.0', 'uvicorn[standard]>=0.34.0', 'mypy>=1.14.0; extra == \"dev\"', 'pytest-asyncio>=0.24.0; extra == \"dev\"', 'pytest-httpx>=0.35.0; extra == \"dev\"', 'pytest>=8.0.0; extra == \"dev\"', 'ruff>=0.8.0; extra == \"dev\"']\nSUMMARY: AirOps Python SDK for building custom tools that integrate with AirOps Steps API\n=== DESCRIPTION ===\n# AirOps Python SDK\n\nBuild custom tools that integrate with AirOps' Steps API.\n\n## Installation\n\nRequires Python 3.13+ and [uv](https://docs.astral.sh/uv/).\n\n```bash\npip install airops\n```\n\n## Quick Start\n\n### Prerequisites\n\n**Docker** is required for building and deploying tools.\n\nInstall Docker:\n- **macOS/Windows**: Download [Docker Desktop](https://www.docker.com/products/docker-desktop/)\n- **Linux**: Install via your package manager:\n  ```bash\n  # Debian/Ubuntu\n  sudo apt-get update && sudo apt-get install docker.io\n\n  # Fedora\n  sudo dnf install docker\n\n  # Arch\n  sudo pacman -S docker\n  ```\n\nAfter installation, verify Docker is running:\n```bash\ndocker --version\n```\n\n### Create a Tool\n\nCreate a new tool project:\n\n```bash\nairops init my-tool\ncd my-tool\ncp .env.example .env  # add your AIROPS_API_TOKEN\nairops run\n```\n\nThis creates a ready-to-run project with:\n- `tool.py` - Example tool implementation\n- `Dockerfile` - Container image for deployment\n- `.env.example` - Environment variable template\n- `tests/` - Example test suite\n\n## Example\n\n```python\nfrom pydantic import Field\nfrom airops import Tool, ToolOutputs, steps\nfrom airops.inputs import ToolInputs, ShortText, Number\n\n\nclass Inputs(ToolInputs):\n    url: ShortText = Field(..., description=\"Domain or URL to search\")\n    limit: Number = Field(default=5, description=\"Maximum results\")\n\n\nclass Outputs(ToolOutputs):\n    results: list[dict]\n\n\ntool = Tool(\n    name=\"keyword_extractor\",\n    description=\"Extract keywords from a URL using Google Search\",\n    input_model=Inputs,\n    output_model=Outputs,\n)\n\n\n@tool.handler\nasync def run(inputs: Inputs) -> Outputs:\n    # Call AirOps Steps API\n    serp = await steps.execute(\n        \"google_search\",\n        {\"query\": f\"site:{inputs.url}\", \"limit\": inputs.limit},\n    )\n    return Outputs(results=serp[\"results\"])\n\n\nif __name__ == \"__main__\":\n    tool.serve()\n```\n\n## Input Types\n\nUse AirOps input types for workflow-compatible schemas:\n\n```python\nfrom airops.inputs import (\n    ToolInputs,     # Base class for inputs (required)\n    ShortText,      # Single-line text (str)\n    LongText,       # Multi-line text (str)\n    Number,         # Numeric value (int | float)\n    Json,           # JSON value (dict, list, primitives)\n    SingleSelect,   # Single choice from options\n    MultiSelect,    # Multiple choices from options\n    KnowledgeBase,  # Knowledge base ID (int)\n    Brandkit,       # Brandkit ID (int)\n    Database,       # Database ID (int)\n)\n\nclass Inputs(ToolInputs):\n    query: ShortText = Field(..., description=\"Search query\")\n    context: LongText = Field(default=\"\", description=\"Additional context\")\n    limit: Number = Field(default=10)\n    format: SingleSelect(\"json\", \"csv\", \"xml\") = Field(default=\"json\")\n    tags: MultiSelect(\"urgent\", \"important\") = Field(default=[])\n    kb_id: KnowledgeBase = Field(..., description=\"Knowledge base to use\")\n```\n\nGet the AirOps workflow schema:\n\n```python\nprint(tool.airops_inputs_schema)\n# [{\"name\": \"query\", \"interface\": \"short_text\", \"label\": \"Query\", ...}, ...]\n```\n\n## Configuration\n\nSet the following environment variables:\n\n```bash\n# Required\nexport AIROPS_API_TOKEN=\"your-api-token\"\n\n# Optional\nexport AIROPS_API_BASE_URL=\"https://api.airops.com\"  # default\nexport AIROPS_DEFAULT_TIMEOUT_S=\"7200\"               # 2 hours default\nexport AIROPS_POLL_INTERVAL_S=\"2.0\"                  # default\n```\n\n## MCP Integration\n\nThe AirOps MCP provides tools for discovering available steps, schemas, and workspace secrets.\n\nTo use the MCP you will have to configure it with your workspace API key, which can be found in the AirOps app under `Settings -> Workspace -> API KEY`\n\n### Claude Desktop\n\n```bash\nclaude mcp add --transport http airops-sdk-mcp https://app.airops.com/internal_api/sdk/mcp --header \"Authorization: Bearer <YOUR_WORKSPACE_TOKEN>\"\n```\n\n### Cursor\n\nAdd to your `mcp.json` file in Cursor Settings:\n\n```json\n{\n  \"mcpServers\": {\n    \"airops-sdk-mcp\": {\n      \"url\": \"https://app.airops.com/internal_api/sdk/mcp\",\n      \"headers\": {\n        \"Authorization\": \"Bearer <YOUR_WORKSPACE_TOKEN>\"\n      }\n    }\n  }\n}\n```\n\nThe MCP exposes:\n- Step discovery and search\n- Input/output schema inspection\n- Workspace secrets listing\n- Tool building guidance at `/airops-internal/tool_building_prompt`\n\n## Usage\n\n### Running Locally\n\n```bash\nuv run tool.py\n```\n\nThis starts a local server with:\n- **API**: `http://localhost:8080/runs` - Start and poll tool executions\n- **UI**: `http://localhost:8080/` - Browser-based testing interface\n\n### Using the Steps API\n\n```python\nfrom airops import steps\n\n# Execute a step and wait for completion\nresult = await steps.execute(\"google_search\", {\"query\": \"airops\"})\n\n# Or use start/poll for more control\nhandle = await steps.start(\"google_search\", {\"query\": \"airops\"})\nstatus = await steps.poll(handle.step_execution_id)\n```\n\n### Tool Runtime API\n\n#### Start a run\n```http\nPOST /runs\nContent-Type: application/json\n\n{\"inputs\": {\"url\": \"example.com\"}}\n```\n\nResponse (202):\n```json\n{\"run_id\": \"...\", \"status\": \"queued\"}\n```\n\n#### Poll run status\n```http\nGET /runs/{run_id}\n```\n\nResponse:\n```json\n{\n  \"run_id\": \"...\",\n  \"status\": \"success\",\n  \"outputs\": {\"results\": [...]}\n}\n```\n\n## Publishing to AirOps\n\nDeploy your tool to AirOps for production use:\n\n```bash\nairops publish\n```\n\nOptions:\n- `--name`: Override tool name (default: from tool.py)\n- `--description`: Override description (default: from tool.py)\n- `--timeout`: Deployment timeout in seconds (default: 600)\n\nThe command:\n1. Validates tool.py and Dockerfile exist\n2. Runs type checks with mypy\n3. Builds and exports Docker image\n4. Uploads to AirOps\n5. Monitors deployment until active\n\n## Error Handling\n\n```python\nfrom airops import steps\nfrom airops.errors import (\n    AiropsError,\n    AuthError,\n    InvalidInputError,\n    RateLimitedError,\n    StepFailedError,\n    StepTimeoutError,\n    UpstreamUnavailableError,\n)\n\ntry:\n    result = await steps.execute(\"some_step\", inputs)\nexcept StepTimeoutError:\n    print(\"Step timed out\")\nexcept StepFailedError as e:\n    print(f\"Step failed: {e.error_details}\")\nexcept AiropsError as e:\n    print(f\"AirOps error: {e}\")\n```\n\n## Development\n\n```bash\n# Install dev dependencies\nuv sync --dev\n\n# Run tests\nuv run pytest\n\n# Type checking\nuv run mypy src/airops\n\n# Linting\nuv run ruff check src/airops\n```\n\n## Using custom tools with agents\nThe custom tools built and published with the AirOps SDK can be used through the Tool Execution MCP server. This MCP server also requires setting it up with your workspace token and will discover all published tools and serve them as MCP tools your agent can use.\n\n### Claude Desktop\n\n```bash\nclaude mcp add --transport http airops-tools-mcp https://app.airops.com/internal_api/tools/mcp --header \"Authorization: Bearer <YOUR_WORKSPACE_TOKEN>\"\n```\n\n### Cursor\n\nAdd to your `mcp.json` file in Cursor Settings:\n\n```json\n{\n  \"mcpServers\": {\n    \"airops-tools-mcp\": {\n      \"url\": \"https://app.airops.com/internal_api/tools/mcp\",\n      \"headers\": {\n        \"Authorization\": \"Bearer <YOUR_WORKSPACE_TOKEN>\"\n      }\n    }\n  }\n}\n```\n","type":"text"}]}
$ cd /tmp && timeout 60 curl -sS https://registry.npmjs.org/@airops/airops-js -o airops_npm.json && python3 -c "{"content":[{"text":"LATEST: {'latest': '2.3.0'}\nVERSIONS: ['1.0.0', '1.0.1', '1.0.2', '1.0.3', '1.1.0', '1.2.0', '1.3.0', '1.4.0', '2.0.0', '2.1.0', '2.2.0', …ctrl+o
cd /tmp && timeout 60 curl -sS https://registry.npmjs.org/@airops/airops-js -o airops_npm.json && python3 -c "
import json
d=json.load(open('airops_npm.json'))
print('LATEST:', d['dist-tags'])
print('VERSIONS:', list(d['versions'].keys()))
v=d['versions'][d['dist-tags']['latest']]
print('DEPS:', v.get('dependencies'))
print('=== README ===')
print((d.get('readme') or '')[:6000])
"

{"content":[{"text":"LATEST: {'latest': '2.3.0'}\nVERSIONS: ['1.0.0', '1.0.1', '1.0.2', '1.0.3', '1.1.0', '1.2.0', '1.3.0', '1.4.0', '2.0.0', '2.1.0', '2.2.0', '2.3.0']\nDEPS: {'is-builtin-module': '^3.2.1', 'pusher-js': '^8.0.2', 'uuid': '^9.0.0'}\n=== README ===\n# @airops/airops-js\n\nUse [AirOps](https://docs.airops.com/docs/client-sdk) API in your client application with our JavaScript SDK.\n\n[![npm](https://img.shields.io/npm/v/@airops/airops-js.svg)](https://www.npmjs.com/package/@airops/airops-js)\n\n# Getting Started\n\n## Installation\n\n```bash\n npm i @airops/airops-js\n```\n\nor use the CDN:\n\n```html\n<script src=“https://cdn.jsdelivr.net/npm/@airops/airops-js/dist/index.umd.min.js”></script>\n```\n\n## Server configuration\n\nTo authenticate with our API using the SDK, you'll need three pieces of information: your workspace id, API key and user\nid (identifier for the user in your application). First, use the API key to hash your user id on your back-end server. This will result in a\nhashed user id that is unique to your API key and user id combination. Workspace id and API key can be found in your\nworkspace settings.\n\n### Example Node.js backend configuration.\n\n```javascript\nconst crypto = require('crypto');\n\nconst userIdHash = () => {\n  const apiKey = 'YOUR_API_KEY';\n  const userId = 'YOUR_USER_ID';\n\n  // Convert your api key to a buffer\n  const key = Buffer.from(apiKey, 'utf-8');\n\n  // Hash the message using HMAC-SHA256 and the key\n  const hash = crypto.createHmac('sha256', key).update(userId).digest('hex');\n\n  return hash;\n};\n```\n\n## Initialize the SDK\n\n```javascript\nconst airopsInstance = AirOps.identify({\n  userId: 'YOUR_USER_ID',\n  workspaceId: 'YOUR_WORKSPACE_ID',\n  hashedUserId: 'YOUR_USER_ID_HASH',\n});\n```\n\nNote that authorization is **not** required to execute public apps. If you're working with public initialize without arguments:\n\n```javascript\nconst airopsInstance = new AirOps();\n```\n\n## Usage\n\nOnce you have successfully initialized the SDK, you can begin using the methods available to interact with our API.\nNote that the methods will return promises.\n\n### Execute an App\n\nThe SDK provides a method for executing an app. In order to stream the app results you will need to enable stream and pass a callback function to the execute method. Optionally you can pass an extra callback function to get a notification when the app is finished.\n\n```javascript\n// Execute an app\nconst response = await airopsInstance.apps.execute({\n  appId: 1,\n  version: 1,\n  payload: {\n    inputs: {\n      name: 'XXXXYYYYZZZZ',\n    },\n  },\n  stream: true, // Optional - Default false\n  streamCallback: (data: { content: string }) => {\n    // Do something with the data\n  }, // Optional, required if stream is true\n  streamCompletedCallback: (data: { content: string }) => {\n    // Do something with the data\n  }, // Optional\n});\n\n// Wait for result\nconst result = await response.result();\n// Do something with result.output\n\n// Cancel execution\nawait response.cancel();\n```\n\n**Response**\n\nThe response from the execute method will contain the execution id that can be used to retrieve results later along with two methods to wait for results or cancel the execution:\n\n```typescript\ninterface ExecuteResponse {\n  executionId: number;\n  result: () => Promise<AppExecution>;\n  cancel: () => Promise<void>;\n}\n\ninterface AppExecution {\n  airops_app_id: number;\n  id: number;\n  status: string;\n  output: string;\n  stream_channel_id: string;\n}\n```\n\nThe result method implements pulling logic for results with a timeout of **10 minutes**. If you want to implement your own pulling logic you can use the getResults method described below.\n\n### Pull results async\n\nYou can implement your own pulling logic using the getResults method.\n\n```javascript\nconst result = await airopsInstance.apps.getResults({\n  executionId: response.executionId,\n});\n```\n\n### Chat Stream\n\nFor Chat Apps, you can use the `chatStream` method which allows you to send messages to the Chat App.\n\n```javascript\nconst response = await airopsInstance.apps.chatStream({\n  appId: 2,\n  message,\n  inputs: {\n    name: 'XXXXYYYYZZZZ',\n  },\n  streamCallback: ({ action: ChatAction, ...data) => {\n    // ChatAction can either be 'agent-response', 'agent-action' or 'agent-action-error'\n    // data will have different values depending on the action:\n    //   - agent-response: { token: string, stream_finished: boolean, result :string }\n    //   - agent-action: { tool: string, tool_input: Record<string, string> }\n    //   - agent-action-error: { tool: string, tool_error: string }\n  },\n  streamCompletedCallback: (data: { result: string }) => {\n    // do something with data.result\n  },\n  ...(sessionId && { sessionId }), // optionally pass sessionId to continue chat.\n});\n\n// Wait for result\nconst result = await response.result();\n// Do something with result.result\n\n// Cancel execution\nawait response.cancel();\n\n// Use session id to continue chat\nresponse.sessionId;\n```\n\n**Response**\n\nThe response from the chatStream method will contain the sessionId and a result method to wait for the response.\nIn order to continue with the chat pass the sessionId along with the message.\n\n```typescript\nexport interface ChatStreamResponse {\n  sessionId: string;\n  result: Promise<{ result: string }>; // result is a promise that resolves when the execution is completed.\n}\n```\n\n### Error handling\n\n```javascript\ntry {\n  await airopsInstance.apps.execute({\n    appId: 1,\n    version: 4,\n    payload: {\n      inputs: { name: 'XXXXYYYYZZZZ' },\n    },\n  });\n} catch (e) {\n  // Do something with error.message\n}\n```\n\n## Need help?\n\nJoin our AirOps builder [slack](https://join.slack.com/t/airopsbuilders/shared_invite/zt-1whiyc290-fw8tsDn0nq89UqGSXIcUNA) community.\n\n","type":"text"}]}
Thinking…

Now I have two SDKs:

  1. @airops/airops-js — client SDK for executing AirOps apps. Requires workspace ID + API key + hashed user ID. Can execute public apps without auth (new AirOps()).
  2. airops Python SDK — for building custom tools. Requires Docker for building/deploying tools. That's a local service stack requirement for the publish workflow — but the Steps API part (steps.execute) is just HTTP calls to api.airops.com with an API token.

Important constraint from the user: "Stay light: use the hosted product through its SDK or API. Do not start local service stacks or wait for long-running commands; if the quickstart requires either, say so plainly and stop."

The Python SDK quickstart (airops init, airops run, airops publish) requires Docker → local service stack + long-running. But the JS SDK quickstart is pure hosted API. So there IS a developer workflow I can act on — via the JS SDK calling the hosted API. But it requires credentials (workspace ID + API key), which I don't have.

Let me check:

  • Is there a way to execute a public app without credentials? The JS SDK says "authorization is not required to execute public apps. If you're working with public initialize without arguments". So maybe I can actually build and RUN an example against a public app... but I'd need a public app ID. Let me check the API and docs.
  • Pricing: let me fetch airops.com/pricing.

Let me test network access to app.airops.com and api.airops.com, and fetch pricing + docs.

Let me be efficient and parallel.

$ cd /tmp; echo "=== pricing page ==="; timeout 30 curl -sSL -A "Mozilla/5.0" https://www.airops.com/pricing -o pricing.html -w "HTTP %{http_code} size=%{size_download} url=%{url_effective}\n"; python3 …{"content":[{"text":"=== pricing page ===\nHTTP 200 size=179967 url=https://www.airops.com/pricing\n AirOps Pricing 2026 | AI Search for Enterprise Skip to Main…ctrl+o
cd /tmp; echo "=== pricing page ==="; timeout 30 curl -sSL -A "Mozilla/5.0" https://www.airops.com/pricing -o pricing.html -w "HTTP %{http_code} size=%{size_download} url=%{url_effective}\n"; python3 - <<'EOF'
import re,html
try:
    t=open('/tmp/pricing.html',encoding='utf-8',errors='ignore').read()
except Exception as e:
    print("ERR",e); raise SystemExit
t=re.sub(r'<script[^>]*>.*?</script>','',t,flags=re.S|re.I)
t=re.sub(r'<style[^>]*>.*?</style>','',t,flags=re.S|re.I)
t=re.sub(r'<[^>]+>',' ',t)
t=html.unescape(t); t=re.sub(r'\s+',' ',t)
print(t[:6000])
EOF

{"content":[{"text":"=== pricing page ===\nHTTP 200 size=179967 url=https://www.airops.com/pricing\n AirOps Pricing 2026 | AI Search for Enterprise Skip to Main Content Why AirOps Solutions Platform Pricing Login Login Work with us Work with us Platform Pricing The system built for every stage of growth Straightforward pricing that grows with you. Work with us Work with us Start for free Start for free Solo For individuals building content for one brand or project. ChatGPT Insights Only 35,000 Tasks for Content Production 1 Brand Kit, 3 Knowledge Bases Basic CMS & SEO Integrations Community & Live Chat Support Single-User Access Start for free Start for free Pro For small teams building content for one brand or project. Insights Across 7+ Answer Engines 100,000 Tasks for Content Production 1 Brand Kit, 5 Knowledge Bases CMS, SEO, AEO, Social & Project Integrations Community & Live Chat Support Unlimited Seats for Your Team Start for free Start for free Enterprise For organizations building enterprise-grade content and tailored solutions. Custom Prompts & Pages Limits Multiple Regions, Personas & Languages Tracked Custom Agent Builds Custom Task Limits Unlimited Knowledge Bases & Brand Kits Dedicated Account Manager & Training 1:1 Expert Onboarding Unlimited Seats & Collaboration Functionality Work with us Work with us 1,000 tasks/month 10,000 tasks/month Thank you! Your submission has been received! Oops! Something went wrong while submitting the form. Solo Start for free Start for free Pro Start for free Start for free Enterprise Work with us Work with us Solo Pro Enterprise Intelligence Answer Engines Agent Analytics Custom AEO Dashboards ChatGPT Ads Insights (Beta) Sentiment & Theme Analysis Competitor Analysis Citation & Source Analysis Opportunities Owned, External Owned, External Owned, External Actions Tasks Brand Kits Knowledge Bases Collaboration Quill Playbooks & Workflows Campaigns Content Review & Quality Scores Human Review & Approvals Inbox Collaborative Artifacts & Version History 35,000 1 3 100,000 1 5 Custom Unlimited Unlimited Integrations CMS SEO Research AEO Research Project Management Company Context Social AirOps MCP MCP Connectors Organization Users SSO Bring your own Model API Keys Support Tier Forward Deployed Engineer 1 Community & Live Chat Support Unlimited Community & Live Chat Support Unlimited Dedicated Account Manager Add-On Turn the AI-first buyer shift into a category advantage Whether you’re starting from square one with AI discovery or ramping a successful program, our team will meet you where you are to drive results. AirOps is the partner behind your brand's AI search strategy, from the first move to the last mile Systems Turn your brand’s opportunities into results on the AirOps platform Learn more Learn more Learn more Strategy Bring on a team of experts to help you set and execute the right strategy Work with us Work with us Learn more Channels Invest in the channels that will drive maximum results for your brand Learn more Learn more Learn more Frequently Asked Questions How does pricing work? Our pricing is based on your task volume and specific needs. We'll work with you to understand your requirements and create a package that aligns with your goals. What are tasks? Tasks are the “currency” used to complete certain actions in AirOps like generating content or extracting data. Not every step in a workflow is a task; only specific step types count toward your usage. The number of tasks depends on both the step type and the data being processed. You only pay for the tasks your workflows actually run—nothing more. Each workspace gets 50,000 free credits to test workflows in the workflow builder before going live for Scale, Enterprise, and 14-day free trial accounts. After that, task-based billing begins. How should I estimate the number of tasks I need? The number of tasks you'll need depends on a number of factors. Tasks are counted every time an AirOps workflow successfully completes an action. As you add more data sources, AI model queries and other steps to your workflows, you can expect the task usage to increase. If you would like further help in estimating your task usage for workflows, contact our experts . What happens if I run out of tasks? If you run out of tasks, you can keep using AirOps but you will be charged for incremental usage beyond your initial allotment. For Solo plan users, additional tasks cost $0.025 per task. Your tasks will automatically reset to your plan's monthly allotted amount at the beginning of the next calendar month. How does the Free Trial work? AirOps 14-day Free Trial gives you access to all Scale plan features for a 14 day period. No need to provide payment details until the end of the trial. Your trial will end when you either 1.) Run out of tasks allotted in the trial 2.) the 14-day period elapses, or 3.) you elect to end the trial early and upgrade to a paid plan. How can I track my task usage? You can easily track your task usage through the AirOps platform. From app.airops.com follow the navigation path Settings>Billing and you will see the tasks you’ve used so far in the monthly billing period and when your tasks will reset. You can also track tasks and usage at the workflow level via Monitoring from the home screen. Can I use my own API keys with AirOps? Yes! If you are on a Solo plan, the ability to add your own keys can be added to your plan. You can reach out to our team below for more details. For Scale and Agency plan users, this is included in your plan. Do you have discounted plan options for early stage startups and students? Yes! If interested, reach out to our team here to discuss options. General Platform Solutions Why AirOps? Blog Pricing Careers Documentation AI Instructions Resources Events AirOps University Research Newsletter Reports Compare AirOps Customer Stories Topics AEO GEO AI search & visibility Content strategy AI workflows External content Enterprise buying Support Talk with us Contact Legal Privacy Policy Terms Social LinkedIn X (Twitter) YouTube Instagram Facebook © 2026 A\n","type":"text"}]}
$ cd /tmp; echo "=== api.airops.com root ==="; timeout 20 curl -sS -o /dev/null -w "HTTP %{http_code}\n" https://api.airops.com/ 2>&1; echo "=== app.airops.com ==="; timeout 20 curl -sS -o /dev/null -w …{"content":[{"text":"=== api.airops.com root ===\nHTTP 302\n=== app.airops.com ===\nHTTP 302\n=== unauth steps probe ===\n{\"error\":\"Not Found\"}\nHTTP 404\n=…ctrl+o
cd /tmp; echo "=== api.airops.com root ==="; timeout 20 curl -sS -o /dev/null -w "HTTP %{http_code}\n" https://api.airops.com/ 2>&1; echo "=== app.airops.com ==="; timeout 20 curl -sS -o /dev/null -w "HTTP %{http_code}\n" https://app.airops.com/ 2>&1; echo "=== unauth steps probe ==="; timeout 20 curl -sS -X POST https://api.airops.com/steps/execute -H 'Content-Type: application/json' -d '{"step":"google_search","inputs":{"query":"test"}}' -w "\nHTTP %{http_code}\n" 2>&1 | head -20; echo "=== docs.airops.com ==="; timeout 25 curl -sS -o /dev/null -w "HTTP %{http_code}\n" https://docs.airops.com/ 2>&1

{"content":[{"text":"=== api.airops.com root ===\nHTTP 302\n=== app.airops.com ===\nHTTP 302\n=== unauth steps probe ===\n{\"error\":\"Not Found\"}\nHTTP 404\n=== docs.airops.com ===\nHTTP 200\n","type":"text"}]}
Thinking…

Good. Pricing is clear. Now: can I actually build and run an example without credentials?

The JS SDK allows executing public apps without auth. Let me check the API surface of @airops/airops-js to find the endpoint used, and see if there's a known public app. Let me install the JS SDK and inspect it — that's light (npm install of a small package). Actually, let me first check if there's a public app ID I could use. Probably not discoverable.

Let me check the docs for API reference and whether there's a sandbox/demo workspace. Also check if npm install works in this sandbox (offline?). Let's try installing @airops/airops-js — small deps (pusher-js, uuid, is-builtin-module).

Key blocker assessment: Without an AirOps workspace API key (which requires signing up for an account — a human action, likely with email/SSO), I cannot execute anything real. Let me verify by probing the API endpoints to confirm auth is required.

Let me look at the JS SDK source to determine actual endpoints, then probe them unauthenticated to demonstrate the concrete blocker. That's a solid, honest deliverable: build the example code, verify it's syntactically valid and wired correctly, and show exactly where it stops (401 without a workspace key).

Let's do it.

$ mkdir -p /tmp/probe && cd /tmp/probe && timeout 120 npm init -y >/dev/null 2>&1 && timeout 180 npm i @airops/airops-js 2>&1 | tail -5; echo "--- installed ---"; ls node_modules/@airops/airops-js/dist …{"content":[{"text":"\nSome issues need review, and may require choosing\na different dependency.\n\nRun `npm audit` for details.\n--- installed ---\nAirOps.d.t…ctrl+o
mkdir -p /tmp/probe && cd /tmp/probe && timeout 120 npm init -y >/dev/null 2>&1 && timeout 180 npm i @airops/airops-js 2>&1 | tail -5; echo "--- installed ---"; ls node_modules/@airops/airops-js/dist 2>/dev/null | head

{"content":[{"text":"\nSome issues need review, and may require choosing\na different dependency.\n\nRun `npm audit` for details.\n--- installed ---\nAirOps.d.ts\nAirOps.test.d.ts\nAirOpsApps.d.ts\nAirOpsApps.test.d.ts\nCustomFetch.d.ts\nPusher.d.ts\nerrors\nindex.cjs.js\nindex.d.ts\nindex.esm.js\n","type":"text"}]}
$ cd /tmp; timeout 30 curl -sSL -A "Mozilla/5.0" https://docs.airops.com/docs/client-sdk -o sdkdocs.html -w "HTTP %{http_code}\n"; python3 - <<'EOF'{"content":[{"text":"HTTP 404\n AirOps Docs \n","type":"text"}]}ctrl+o
cd /tmp; timeout 30 curl -sSL -A "Mozilla/5.0" https://docs.airops.com/docs/client-sdk -o sdkdocs.html -w "HTTP %{http_code}\n"; python3 - <<'EOF'
import re,html
t=open('/tmp/sdkdocs.html',encoding='utf-8',errors='ignore').read()
t=re.sub(r'<script[^>]*>.*?</script>','',t,flags=re.S|re.I)
t=re.sub(r'<style[^>]*>.*?</style>','',t,flags=re.S|re.I)
t=re.sub(r'<[^>]+>',' ',t); t=html.unescape(t); t=re.sub(r'\s+',' ',t)
print(t[:2500])
EOF

Thinking…

The npm install worked. Let me inspect the SDK internals to find the API endpoints and confirm auth requirements.

$ cd /tmp/probe/node_modules/@airops/airops-js && ls; echo "=== index.d.ts ==="; cat dist/index.d.ts; echo "=== AirOps.d.ts ==="; cat dist/AirOps.d.ts; echo "=== AirOpsApps.d.ts ==="; cat dist/AirOpsApp…{"content":[{"text":"LICENSE\nREADME.md\ndist\npackage.json\n=== index.d.ts ===\nimport AirOps from './AirOps';\nimport { ChatAction, ChatStreamResponse } from …ctrl+o
cd /tmp/probe/node_modules/@airops/airops-js && ls; echo "=== index.d.ts ==="; cat dist/index.d.ts; echo "=== AirOps.d.ts ==="; cat dist/AirOps.d.ts; echo "=== AirOpsApps.d.ts ==="; cat dist/AirOpsApps.d.ts

{"content":[{"text":"LICENSE\nREADME.md\ndist\npackage.json\n=== index.d.ts ===\nimport AirOps from './AirOps';\nimport { ChatAction, ChatStreamResponse } from './ts/types';\nexport { ChatAction, ChatStreamResponse };\nexport default AirOps;\n=== AirOps.d.ts ===\nimport AirOpsApps from './AirOpsApps';\nimport { IdentifyParams, AirOpsInterface } from './ts/types';\ndeclare class AirOps implements AirOpsInterface {\n    private readonly workspaceId?;\n    private readonly userId?;\n    private readonly hashedUserId?;\n    private readonly host?;\n    constructor(params?: IdentifyParams);\n    /**\n     * AirOps indentify\n     * @param params\n     * @param params.userId - User ID\n     * @param params.workspaceId - Workspace ID\n     * @param params.hashedUserId - Hashed User ID\n     * @param params.host - Host URL optional\n     * @returns - AirOps instance\n     */\n    static identify(params: IdentifyParams): AirOps;\n    /**\n     * AirOps indentify\n     * @returns - Apps instance\n     */\n    get apps(): AirOpsApps;\n}\nexport default AirOps;\n=== AirOpsApps.d.ts ===\nimport { AppExecution, ChatStreamParams, ChatStreamResponse, ExecuteParams, ExecuteResponse, ExecutionParams, IdentifyParams } from './ts/types';\ndeclare class AirOpsApps {\n    private customFetch;\n    private readonly userId?;\n    private readonly workspaceId?;\n    private readonly hashedUserId?;\n    private readonly host;\n    /**\n     * App constructor\n     * @param params\n     * @param params.userId - User ID\n     * @param params.workspaceId - Workspace ID\n     * @param params.hashedUserId - Hashed User ID\n     * @param params.host - Host URL optional\n     */\n    constructor(params: IdentifyParams);\n    /**\n     * Get app\n     * @param appId - App Id\n     * @returns App execution output\n     */\n    get(appId: number | string): Promise<any>;\n    /**\n     * Execute an app\n     * @param params\n     * @param params.appId - App ID\n     * @param params.version - App version\n     * @param params.payload - App input\n     * @param params.stream - Stream enabled?\n     * @param params.streamCallback - Callback function for stream\n     * @param params.streamCompletedCallback - Callback function for stream completed\n     * @returns - App output\n     */\n    execute(params: ExecuteParams): Promise<ExecuteResponse>;\n    /**\n     * Chat stream\n     * @param params\n     * @param params.message - Message to send\n     * @param params.appId - App ID\n     * @param params.sessionId - Session ID\n     * @param params.streamCallback - Callback function for stream\n     * @param params.streamCompletedCallback - Callback function for stream completed\n     * @returns - Chat stream response\n     */\n    chatStream(params: ChatStreamParams): Promise<ChatStreamResponse>;\n    /**\n     * Get app execution status\n     * @param params\n     * @param params.appId - App Id\n     * @param params.executionId - Execution Id\n     * @returns App execution output\n     */\n    getResults(params: ExecutionParams): Promise<AppExecution>;\n    /**\n     * Cancel execution\n     * @param params\n     * @param params.appId - App ID\n     * @param params.executionId - Execution ID\n     */\n    private cancelExecution;\n    private pollExecutionResult;\n    private initializePusher;\n}\nexport default AirOpsApps;\n","type":"text"}]}
$ cd /tmp/probe/node_modules/@airops/airops-js && grep -oE "https?://[a-zA-Z0-9./_-]+" dist/index.cjs.js | sort -u; echo "--- endpoints ---"; grep -oE '"/[a-zA-Z0-9/_.${}-]+"' dist/index.cjs.js | sort -…{"content":[{"text":"http://js.pusher.com\nhttp://tweetnacl.cr.yp.to/\nhttps://app.airops.com\nhttps://github.com/floodyberry/poly1305-donna\nhttps://github.com…ctrl+o
cd /tmp/probe/node_modules/@airops/airops-js && grep -oE "https?://[a-zA-Z0-9./_-]+" dist/index.cjs.js | sort -u; echo "--- endpoints ---"; grep -oE '"/[a-zA-Z0-9/_.${}-]+"' dist/index.cjs.js | sort -u | head -40; echo "--- pkg ---"; python3 -c "
import json;d=json.load(open('package.json'));print({k:d.get(k) for k in ['name','version','main','module','types','exports','files']})"

{"content":[{"text":"http://js.pusher.com\nhttp://tweetnacl.cr.yp.to/\nhttps://app.airops.com\nhttps://github.com/floodyberry/poly1305-donna\nhttps://github.com/pusher/pusher-js/tree/cc491015371a4bde5743d1c87a0fbac0feb53195\nhttps://github.com/uuidjs/uuid\nhttps://github.com/uuidjs/uuid/pull/434\nhttps://js.pusher.com\nhttps://pusher.com\nhttps://pusher.com/\n--- endpoints ---\n--- pkg ---\n{'name': '@airops/airops-js', 'version': '2.3.0', 'main': 'dist/index.cjs.js', 'module': 'dist/index.esm.js', 'types': 'dist/index.d.ts', 'exports': None, 'files': ['dist', 'README.md', 'package.json']}\n","type":"text"}]}
Thinking…

The SDK defaults to host https://app.airops.com. Let me find the endpoint paths in the bundle.

$ cd /tmp/probe/node_modules/@airops/airops-js && grep -oE '`[^`]{0,80}`' dist/index.cjs.js | grep -E 'api|apps|execut' | sort -u | head -30; echo "--- context ---"; grep -oE '.{140}(apps/|/api/).{140}'…{"content":[{"text":"`${host}/sdk_api/pusher/auth`\n`${this.host}/sdk_api/agent_apps/${appId}/chat_stream`\n`${this.host}/sdk_api/airops_app_bases/${appId}?meth…ctrl+o
cd /tmp/probe/node_modules/@airops/airops-js && grep -oE '`[^`]{0,80}`' dist/index.cjs.js | grep -E 'api|apps|execut' | sort -u | head -30; echo "--- context ---"; grep -oE '.{140}(apps/|/api/).{140}' dist/index.cjs.js | head -20

{"content":[{"text":"`${host}/sdk_api/pusher/auth`\n`${this.host}/sdk_api/agent_apps/${appId}/chat_stream`\n`${this.host}/sdk_api/airops_app_bases/${appId}?methods=type`\n`${this.host}/sdk_api/airops_apps/${appId}/async_execute/v${version}`\n`${this.host}/sdk_api/airops_apps/${appId}/async_execute`\n`${this.host}/sdk_api/airops_apps/executions/${executionId}/cancel`\n`${this.host}/sdk_api/airops_apps/executions/${executionId}`\n--- context ---\n","type":"text"}]}
Thinking…

Now I know the endpoints. Let me probe unauthenticated to confirm the auth requirement and find whether a public app exists.

Let me test: GET https://app.airops.com/sdk_api/airops_app_bases/1?methods=type without auth.

$ for id in 1 2 100; do echo "--- app_base $id ---"; timeout 15 curl -sS "https://app.airops.com/sdk_api/airops_app_bases/$id?methods=type" -H 'Content-Type: application/json' -w "\nHTTP %{http_code}\n"…{"content":[{"text":"--- app_base 1 ---\n{\"error\":\"Not Found\"}\nHTTP 404\n--- app_base 2 ---\n{\"error\":\"Not Found\"}\nHTTP 404\n--- app_base 100 ---\n{\"…ctrl+o
for id in 1 2 100; do echo "--- app_base $id ---"; timeout 15 curl -sS "https://app.airops.com/sdk_api/airops_app_bases/$id?methods=type" -H 'Content-Type: application/json' -w "\nHTTP %{http_code}\n" 2>&1 | head -6; done; echo "=== async_execute no auth ==="; timeout 15 curl -sS -X POST "https://app.airops.com/sdk_api/airops_apps/1/async_execute" -H 'Content-Type: application/json' -d '{"payload":{"inputs":{}}}' -w "\nHTTP %{http_code}\n" 2>&1 | head -6

{"content":[{"text":"--- app_base 1 ---\n{\"error\":\"Not Found\"}\nHTTP 404\n--- app_base 2 ---\n{\"error\":\"Not Found\"}\nHTTP 404\n--- app_base 100 ---\n{\"error\":\"Not Found\"}\nHTTP 404\n=== async_execute no auth ===\n{\"error\":\"Not Authorized\"}\nHTTP 401\n","type":"text"}]}
Thinking…

Confirmed: the API is live and reachable, but execution requires workspace credentials (workspaceId + hashedUserId derived from the workspace API key). There are no discoverable public apps (404 on app_bases). And there's no AIROPS_API_KEY / workspace token in my environment.

So the situation:

  • AirOps DOES have a real developer workflow I can act on (JS SDK → hosted sdk_api, plus a Steps API and MCP endpoints). So I should NOT stop per "no developer workflow" clause.
  • BUT the Python SDK quickstart requires Docker (local service stack) + long-running airops publish deployment → that path violates the user's "stay light" constraint. I should say so plainly.
  • The JS SDK path is pure hosted API — light. But it's hard-blocked on credentials I don't have. Signup requires a human (account creation at app.airops.com, then Settings → Workspace → API KEY).

So: I'll build the example (real, runnable code) using the JS SDK, verify it loads/compiles and that it correctly reaches the live API, and report the precise blocker (401 Not Authorized) with the exact credentials needed. That's honest and maximally useful without fabricating success.

Let me build a small, clean Node example in /sandbox/repo:

  • package.json
  • .env.example
  • src/hashUserId.js (HMAC-SHA256 helper, matching the SDK's documented scheme)
  • src/runApp.js (execute an app, with polling fallback and streaming optional)
  • src/chat.js maybe — keep it simple; one main example + a smoke check.
  • scripts/check-connection.js — a light preflight that verifies credentials/endpoint reachability without running a workflow (avoids burning tasks).

Let me verify the SDK works in Node: it's a CJS/ESM bundle. Note is-builtin-module and pusher-js — pusher-js in Node needs a node-compatible build; the bundle may reference js.pusher.com. Streaming via Pusher may not work in plain Node without config; I'll default stream: false and use polling (result() / getResults), which is pure HTTP. Good — that keeps it light and avoids websocket deps.

Let me check whether the CJS bundle loads in Node without a browser environment.

Let me write the files and test the load + a dry-run against the live API showing the 401.

First check node version and whether importing works.

$ node -v; cd /tmp/probe && cat > loadtest.cjs <<'EOF'{"content":[{"text":"v22.23.2\n/tmp/probe/node_modules/@airops/airops-js/dist/index.cjs.js:114\n\t})(window, function() {\n\t ^\n\nReferenceError: window is n…ctrl+o
node -v; cd /tmp/probe && cat > loadtest.cjs <<'EOF'
const AirOps = require('@airops/airops-js').default || require('@airops/airops-js');
const a = new AirOps();
console.log("loaded OK; apps methods:", Object.getOwnPropertyNames(Object.getPrototypeOf(a.apps)).filter(m=>m!=='constructor').join(", "));
EOF
timeout 30 node loadtest.cjs 2>&1 | tail -20

{"content":[{"text":"v22.23.2\n/tmp/probe/node_modules/@airops/airops-js/dist/index.cjs.js:114\n\t})(window, function() {\n\t   ^\n\nReferenceError: window is not defined\n    at /tmp/probe/node_modules/@airops/airops-js/dist/index.cjs.js:114:5\n    at Object.<anonymous> (/tmp/probe/node_modules/@airops/airops-js/dist/index.cjs.js:7088:3)\n    at Module._compile (node:internal/modules/cjs/loader:1781:14)\n    at Object..js (node:internal/modules/cjs/loader:1913:10)\n    at Module.load (node:internal/modules/cjs/loader:1505:32)\n    at Function._load (node:internal/modules/cjs/loader:1309:12)\n    at wrapModuleLoad (node:internal/modules/cjs/loader:254:19)\n    at Module.require (node:internal/modules/cjs/loader:1527:12)\n    at require (node:internal/modules/helpers:147:16)\n    at Object.<anonymous> (/tmp/probe/loadtest.cjs:1:16)\n\nNode.js v22.23.2\n","type":"text"}]}
Thinking…

The JS SDK bundle expects a browser window (pusher-js UMD). Two options:

  1. Shim a minimal window global before requiring — a common workaround.
  2. Skip the SDK and call the documented sdk_api HTTP endpoints directly (plain fetch) — lighter, zero deps, and honest about "through its SDK or API".

Best: do both — primary example uses the SDK with a tiny browser-global shim (so it's genuinely "the SDK"), and include a zero-dependency HTTP client that hits the same endpoints so it works in plain Node without shims. Actually, keeping it simple matters more. Let me test the shim approach first; if it works cleanly, use the SDK as the main path.

$ cd /tmp/probe && cat > shim.cjs <<'EOF'{"content":[{"text":" at Object.onDocumentBody (/tmp/probe/node_modules/@airops/airops-js/dist/index.cjs.js:6182:10)\n at initializeOnDocumentBody (/tmp/p…ctrl+o
cd /tmp/probe && cat > shim.cjs <<'EOF'
globalThis.window = globalThis.window || globalThis;
globalThis.self = globalThis.self || globalThis;
if (!globalThis.navigator) globalThis.navigator = { userAgent: 'node' };
const AirOps = require('@airops/airops-js').default;
const a = new AirOps();
console.log("loaded OK; methods:", Object.getOwnPropertyNames(Object.getPrototypeOf(a.apps)).filter(m=>m!=='constructor').join(", "));
EOF
timeout 30 node shim.cjs 2>&1 | tail -12

{"content":[{"text":"    at Object.onDocumentBody (/tmp/probe/node_modules/@airops/airops-js/dist/index.cjs.js:6182:10)\n    at initializeOnDocumentBody (/tmp/probe/node_modules/@airops/airops-js/dist/index.cjs.js:6163:19)\n    at Object.setup (/tmp/probe/node_modules/@airops/airops-js/dist/index.cjs.js:6169:14)\n    at Module.<anonymous> (/tmp/probe/node_modules/@airops/airops-js/dist/index.cjs.js:7064:10)\n    at __webpack_require__ (/tmp/probe/node_modules/@airops/airops-js/dist/index.cjs.js:134:31)\n    at Object.<anonymous> (/tmp/probe/node_modules/@airops/airops-js/dist/index.cjs.js:3040:19)\n    at __webpack_require__ (/tmp/probe/node_modules/@airops/airops-js/dist/index.cjs.js:134:31)\n    at /tmp/probe/node_modules/@airops/airops-js/dist/index.cjs.js:198:19\n    at /tmp/probe/node_modules/@airops/airops-js/dist/index.cjs.js:201:11\n    at webpackUniversalModuleDefinition (/tmp/probe/node_modules/@airops/airops-js/dist/index.cjs.js:113:20)\n\nNode.js v22.23.2\n","type":"text"}]}
Thinking…

pusher-js wants a real document.body. I can add a minimal document shim. Let me check what it needs — onDocumentBody probably does document.body ? fn() : document.addEventListener('DOMContentLoaded'...). Let me add a fake document with body and addEventListener/createElement. Try it.

Actually, pusher-js in node normally uses pusher-js/node. The bundled browser build needs DOM. A minimal shim may work. Let me try.

If the shim gets hairy, the cleaner engineering call is: use the documented HTTP API directly (zero-dep), which is explicitly allowed ("through its SDK or API"), and note that the official JS SDK is browser-targeted (pusher-js DOM dependency) so it's not usable in plain Node without DOM shims — that's a genuine finding worth reporting.

Let me try one more shim attempt with a slightly fuller document stub.

$ cd /tmp/probe && sed -n '6150,6195p' node_modules/@airops/airops-js/dist/index.cjs.js{"content":[{"text":"\t Transports: transports_transports,\n\t transportConnectionInitializer: transport_connection_initializer,\n\t HTTPFactory: web_h…ctrl+o
{"content":[{"text":"\t    Transports: transports_transports,\n\t    transportConnectionInitializer: transport_connection_initializer,\n\t    HTTPFactory: web_http_http,\n\t    TimelineTransport: jsonp_timeline,\n\t    getXHRAPI() {\n\t        return window.XMLHttpRequest;\n\t    },\n\t    getWebSocketAPI() {\n\t        return window.WebSocket || window.MozWebSocket;\n\t    },\n\t    setup(PusherClass) {\n\t        window.Pusher = PusherClass;\n\t        var initializeOnDocumentBody = () => {\n\t            this.onDocumentBody(PusherClass.ready);\n\t        };\n\t        if (!window.JSON) {\n\t            Dependencies.load('json2', {}, initializeOnDocumentBody);\n\t        }\n\t        else {\n\t            initializeOnDocumentBody();\n\t        }\n\t    },\n\t    getDocument() {\n\t        return document;\n\t    },\n\t    getProtocol() {\n\t        return this.getDocument().location.protocol;\n\t    },\n\t    getAuthorizers() {\n\t        return { ajax: xhr_auth, jsonp: jsonp_auth };\n\t    },\n\t    onDocumentBody(callback) {\n\t        if (document.body) {\n\t            callback();\n\t        }\n\t        else {\n\t            setTimeout(() => {\n\t                this.onDocumentBody(callback);\n\t            }, 0);\n\t        }\n\t    },\n\t    createJSONPRequest(url, data) {\n\t        return new jsonp_request_JSONPRequest(url, data);\n\t    },\n\t    createScriptRequest(src) {\n\t        return new ScriptRequest(src);\n","type":"text"}]}
Thinking…

It needs document.body truthy and document.location.protocol. A tiny shim suffices. Let me test.

$ cd /tmp/probe && cat > shim2.cjs <<'EOF'{"content":[{"text":"SDK loaded in Node OK. apps methods: get, execute, chatStream, getResults, cancelExecution, pollExecutionResult, initializePusher\n","type"…ctrl+o
cd /tmp/probe && cat > shim2.cjs <<'EOF'
const g = globalThis;
g.window = g;
g.self = g;
g.navigator = g.navigator || { userAgent: 'node' };
g.document = g.document || { body: {}, location: { protocol: 'https:' }, addEventListener(){}, createElement(){ return { style:{} }; }, getElementsByTagName(){ return []; } };
const AirOps = require('@airops/airops-js').default;
const a = new AirOps();
console.log("SDK loaded in Node OK. apps methods:", Object.getOwnPropertyNames(Object.getPrototypeOf(a.apps)).filter(m=>m!=='constructor').join(", "));
EOF
timeout 30 node shim2.cjs 2>&1 | tail -8

{"content":[{"text":"SDK loaded in Node OK. apps methods: get, execute, chatStream, getResults, cancelExecution, pollExecutionResult, initializePusher\n","type":"text"}]}
Thinking…

The SDK loads in Node with a tiny DOM shim. Now let me build the real example in /s

$ cd /tmp/probe/node_modules/@airops/airops-js && node -e "{"content":[{"text":">>> async_execute/v${version} \n\n>>> async_execute \n\n--- headers ctx ---\n\"\\t xhr.setRequestHeader('Content-Type', 'application/x-w…ctrl+o
cd /tmp/probe/node_modules/@airops/airops-js && node -e "
const s=require('fs').readFileSync('dist/index.cjs.js','utf8');
for (const re of [/async_execute[^\`]{0,300}/g]) {
  const m=s.match(re); if(m) m.slice(0,4).forEach(x=>console.log('>>>',x.slice(0,400),'\n'));
}
const i=s.indexOf('headers');
console.log('--- headers ctx ---');
let idx=0,n=0;
while((idx=s.indexOf('headers',idx+1))>0 && n<8){console.log(JSON.stringify(s.slice(idx-120,idx+220)));n++;}
"

{"content":[{"text":">>> async_execute/v${version} \n\n>>> async_execute \n\n--- headers ctx ---\n\"\\t    xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');\\n\\t    for (var headerName in authOptions.headers) {\\n\\t        xhr.setRequestHeader(headerName, authOptions.headers[headerName]);\\n\\t    }\\n\\t    if (authOptions.headersProvider != null) {\\n\\t        let dynamicHeaders = authOptions.headersProvider();\\n\\t        for (var\"\n\"-urlencoded');\\n\\t    for (var headerName in authOptions.headers) {\\n\\t        xhr.setRequestHeader(headerName, authOptions.headers[headerName]);\\n\\t    }\\n\\t    if (authOptions.headersProvider != null) {\\n\\t        let dynamicHeaders = authOptions.headersProvider();\\n\\t        for (var headerName in dynamicHeaders) {\\n\\t            xhr.setRequestHeade\"\n\"ions.headers) {\\n\\t        xhr.setRequestHeader(headerName, authOptions.headers[headerName]);\\n\\t    }\\n\\t    if (authOptions.headersProvider != null) {\\n\\t        let dynamicHeaders = authOptions.headersProvider();\\n\\t        for (var headerName in dynamicHeaders) {\\n\\t            xhr.setRequestHeader(headerName, dynamicHeaders[headerName]);\\n\\t      \"\n\".headers[headerName]);\\n\\t    }\\n\\t    if (authOptions.headersProvider != null) {\\n\\t        let dynamicHeaders = authOptions.headersProvider();\\n\\t        for (var headerName in dynamicHeaders) {\\n\\t            xhr.setRequestHeader(headerName, dynamicHeaders[headerName]);\\n\\t        }\\n\\t    }\\n\\t    xhr.onreadystatechange = function () {\\n\\t        if (x\"\n\"th/jsonp_auth.ts\\n\\n\\tvar jsonp = function (context, query, authOptions, authRequestType, callback) {\\n\\t    if (authOptions.headers !== undefined ||\\n\\t        authOptions.headersProvider != null) {\\n\\t        logger.warn(`To send headers with the ${authRequestType.toString()} request, you must use AJAX, rather than JSONP.`);\\n\\t    }\\n\\t    var call\"\n\"xt, query, authOptions, authRequestType, callback) {\\n\\t    if (authOptions.headers !== undefined ||\\n\\t        authOptions.headersProvider != null) {\\n\\t        logger.warn(`To send headers with the ${authRequestType.toString()} request, you must use AJAX, rather than JSONP.`);\\n\\t    }\\n\\t    var callbackName = context.nextAuthCallbackID.toString\"\n\" if (authOptions.headers !== undefined ||\\n\\t        authOptions.headersProvider != null) {\\n\\t        logger.warn(`To send headers with the ${authRequestType.toString()} request, you must use AJAX, rather than JSONP.`);\\n\\t    }\\n\\t    var callbackName = context.nextAuthCallbackID.toString();\\n\\t    context.nextAuthCallbackID++;\\n\\t    var document \"\n\"rt,\\n\\t        authEndpoint: authOptions.endpoint,\\n\\t        auth: {\\n\\t            params: authOptions.params,\\n\\t            headers: authOptions.headers\\n\\t        }\\n\\t    };\\n\\t    return (params, callback) => {\\n\\t        const channel = pusher.channel(params.channelName);\\n\\t        const channelAuthorizer = channelAuthorizerGenerator(channel, depr\"\n","type":"text"}]}
Thinking…

I have what I need. Now let me build the example project. I'll keep it tight and well-documented:

/sandbox/repo
├── README.md
├── package.json
├── .env.example
├── src/
│   ├── browserShim.js      # makes the browser-targeted SDK load in Node
│   ├── client.js           # AirOps.identify(...) w/ HMAC-SHA256 hashed user id
│   └── runApp.js           # execute an app, poll for result, print output
└── scripts/
    └── preflight.js        # light connectivity/auth check, no task spend

Plus verification: run preflight → shows 401 (blocked on creds).

Let me write these files.

Network, registry, and the live AirOps API all respond. Building the example now.

write /sandbox/repo/package.json{"content":[{"text":"Successfully wrote 402 bytes to /sandbox/repo/package.json","type":"text"}]}
write /sandbox/repo/.env.example{"content":[{"text":"Successfully wrote 661 bytes to /sandbox/repo/.env.example","type":"text"}]}
write /sandbox/repo/src/browserShim.js{"content":[{"text":"Successfully wrote 953 bytes to /sandbox/repo/src/browserShim.js","type":"text"}]}
Thinking…

Now env.js (tiny .env loader without deps — python-dotenv equivalent), client.js, runApp.js, preflight.js.

Node 22 supports process.loadEnvFile(). But for portability across Node 18+, write a tiny loader.

write /sandbox/repo/src/env.js{"content":[{"text":"Successfully wrote 1273 bytes to /sandbox/repo/src/env.js","type":"text"}]}
write /sandbox/repo/src/client.js{"content":[{"text":"Successfully wrote 1283 bytes to /sandbox/repo/src/client.js","type":"text"}]}
Thinking…

Now runApp.js — execute an app, poll for result. And preflight.js.

Note: SDK's result() polls with a 10-minute timeout — that could be a "long-running" wait. I should make the polling bounded and configurable so it doesn't hang. I'll use getResults with my own bounded poll loop (default ~60s cap), which respects the user's "do not wait for long-running commands" constraint.

Let me check the SDK's pollExecutionResult to mirror statuses. Let me grep quickly for status strings.

$ cd /tmp/probe/node_modules/@airops/airops-js && grep -oE '(completed|in_progress|queued|failed|canceled|cancelled|timeout|status ===[^;]{0,60})' dist/index.cjs.js | sort | uniq -c | sort -rn | head -2…{"content":[{"text":" 36 timeout\n 4 completed\n 2 status === 200) {\n 1 status === 204) {\n 1 queued\n 1 failed\n--- poll ctx ---\…ctrl+o
cd /tmp/probe/node_modules/@airops/airops-js && grep -oE '(completed|in_progress|queued|failed|canceled|cancelled|timeout|status ===[^;]{0,60})' dist/index.cjs.js | sort | uniq -c | sort -rn | head -20; echo "--- poll ctx ---"; node -e "
const s=require('fs').readFileSync('dist/index.cjs.js','utf8');
const i=s.indexOf('pollExecutionResult');
console.log(s.slice(i-600,i+900));
"

{"content":[{"text":"     36 timeout\n      4 completed\n      2 status === 200) {\n      1 status === 204) {\n      1 queued\n      1 failed\n--- poll ctx ---\nibe(channelName, streamCallback, streamCompletedCallback);\n            }\n            else if (stream) {\n                throw new Error('You must provide a callback function when streaming.');\n            }\n            const url = version\n                ? `${this.host}/sdk_api/airops_apps/${appId}/async_execute/v${version}`\n                : `${this.host}/sdk_api/airops_apps/${appId}/async_execute`;\n            const response = yield this.customFetch.post(url, apiPayload);\n            return {\n                executionId: response.airops_app_execution.uuid,\n                result: () => this.pollExecutionResult(response),\n                cancel: () => __awaiter(this, void 0, void 0, function* () {\n                    unsubscribeMethod === null || unsubscribeMethod === void 0 ? void 0 : unsubscribeMethod();\n                    yield this.cancelExecution({\n                        executionId: response.airops_app_execution.uuid,\n                    });\n                }),\n            };\n        });\n    }\n    /**\n     * Chat stream\n     * @param params\n     * @param params.message - Message to send\n     * @param params.appId - App ID\n     * @param params.sessionId - Session ID\n     * @param params.streamCallback - Callback function for stream\n     * @param params.streamCompletedCallback - Callback function for stream completed\n     * @returns - Chat stream response\n     */\n    chatStream(params) {\n        return __awaiter(this, void 0, void 0, function* () {\n            const { \n","type":"text"}]}
Thinking…

Good: executionId comes from response.airops_app_execution.uuid. Statuses include completed, queued, failed.

Let me write runApp.js with a bounded poll loop using getResults.

Also note the SDK's getResults({executionId}) — from the .d.ts it takes ExecutionParams with appId and executionId; but the endpoint is /sdk_api/airops_apps/executions/${executionId} so appId isn't needed in URL. I'll pass both anyway for safety.

Let me check customFetch error handling to know what an error looks like (to produce good messages).

$ cd /tmp/probe/node_modules/@airops/airops-js && node -e "{"content":[{"text":"\n resolve(true);\n });\n });\n }\n}\n\nclass ApiError extends Error {\n constructor(error) {\n …ctrl+o
cd /tmp/probe/node_modules/@airops/airops-js && node -e "
const s=require('fs').readFileSync('dist/index.cjs.js','utf8');
const i=s.indexOf('class CustomFetch');
const j=i>=0?i:s.indexOf('customFetch');
console.log(s.slice(j-200,j+1800));
" ; echo "=== errors dir ==="; ls dist/errors; cat dist/errors/*.d.ts 2>/dev/null | head -40

{"content":[{"text":"\n                resolve(true);\n            });\n        });\n    }\n}\n\nclass ApiError extends Error {\n    constructor(error) {\n        super(error.message);\n        this.status = error.status;\n    }\n}\n\nclass CustomFetch {\n    constructor(userId, workspaceId, hashedUserId) {\n        this.userId = userId;\n        this.workspaceId = workspaceId;\n        this.hashedUserId = hashedUserId;\n    }\n    post(url, payload) {\n        return __awaiter(this, void 0, void 0, function* () {\n            const headers = this.getHeaders();\n            const response = yield fetch(url, {\n                method: 'POST',\n                headers,\n                body: JSON.stringify(payload),\n            });\n            if (!response.ok) {\n                yield this.handleError(response);\n            }\n            try {\n                return yield response.json();\n            }\n            catch (e) {\n                // Can't parse response (i.e: empty response)\n            }\n        });\n    }\n    patch(url, payload) {\n        return __awaiter(this, void 0, void 0, function* () {\n            const headers = this.getHeaders();\n            const response = yield fetch(url, Object.assign({ method: 'PATCH', headers }, (payload !== undefined && {\n                body: JSON.stringify(payload),\n            })));\n            if (!response.ok) {\n                yield this.handleError(response);\n            }\n            if (response.status === 204) {\n                return;\n            }\n            return yield response.json();\n        });\n    }\n    get(url) {\n        return __awaiter(this, void 0, void 0, function* () {\n            const headers = this.getHeaders();\n            const response = yield fetch(url, { method: 'GET', headers });\n            if (!response.ok) {\n                yield this.handleError(response);\n            }\n            return yield response.json();\n        });\n    }\n    getHeaders() {\n        const headers = new Headers();\n        headers.append('content-type', 'applica\n=== errors dir ===\napiError.d.ts\nexport declare class ApiError extends Error {\n    status: number;\n    constructor(error: {\n        status: number;\n        message: string;\n    });\n}\n","type":"text"}]}
Thinking…

Good. Now write runApp.js and preflight.js.

For preflight: it should verify credentials & connectivity without spending tasks. What's a free call? apps.get(appId) → GET /sdk_api/airops_app_bases/${appId}?methods=type. That reads app metadata, doesn't execute → no task spend. Perfect for preflight: it validates workspace credentials AND that the app id exists, without burning tasks.

Let me write both.

write /sandbox/repo/scripts/preflight.js{"content":[{"text":"Successfully wrote 1665 bytes to /sandbox/repo/scripts/preflight.js","type":"text"}]}
write /sandbox/repo/src/runApp.js{"content":[{"text":"Successfully wrote 3603 bytes to /sandbox/repo/src/runApp.js","type":"text"}]}
Thinking…

Now let's verify: install deps in /sandbox/repo, run preflight with dummy creds to see the real API response (proving the wiring works and showing the exact blocker).

Note .gitignore doesn't ignore .env — let me add .env to .gitignore to avoid leaking secrets. Important.

Let me install and test.

edit /sandbox/repo/.gitignore{"content":[{"text":"Successfully replaced 1 block(s) in /sandbox/repo/.gitignore.","type":"text"}],"details":{"diff":" ...\n 3 # Logs / OS cruft\n 4 *.log…ctrl+o
{"content":[{"text":"Successfully replaced 1 block(s) in /sandbox/repo/.gitignore.","type":"text"}],"details":{"diff":"    ...\n  3 # Logs / OS cruft\n  4 *.log\n  5 .DS_Store\n  6 \n+ 7 # Secrets - never commit workspace API keys\n+ 8 .env\n+ 9 .env.local\n+10 .env.*.local\n+11 \n  7 # JavaScript / TypeScript\n  8 node_modules/\n  9 .next/\n 10 dist/\n    ...","patch":"--- /sandbox/repo/.gitignore\n+++ /sandbox/repo/.gitignore\n@@ -3,8 +3,13 @@\n # Logs / OS cruft\n *.log\n .DS_Store\n \n+# Secrets - never commit workspace API keys\n+.env\n+.env.local\n+.env.*.local\n+\n # JavaScript / TypeScript\n node_modules/\n .next/\n dist/\n","firstChangedLine":7}}
$ cd /sandbox/repo && timeout 180 npm install 2>&1 | tail -4; echo "=== syntax check ==="; for f in src/*.js scripts/*.js; do node --check "$f" && echo "ok: $f"; done{"content":[{"text":"Some issues need review, and may require choosing\na different dependency.\n\nRun `npm audit` for details.\n=== syntax check ===\nok: src/b…ctrl+o
{"content":[{"text":"Some issues need review, and may require choosing\na different dependency.\n\nRun `npm audit` for details.\n=== syntax check ===\nok: src/browserShim.js\nok: src/client.js\nok: src/env.js\nok: src/runApp.js\nok: scripts/preflight.js\n","type":"text"}]}
Thinking…

Now test end-to-end wiring with placeholder credentials to prove the code path reaches the live API and to surface the exact blocker. Also test the HMAC helper against the documented scheme.

Let me run preflight with dummy values.

$ cd /sandbox/repo && AIROPS_WORKSPACE_ID=1 AIROPS_API_KEY=placeholder-key AIROPS_USER_ID=demo-user-1 AIROPS_APP_ID=1 timeout 60 node scripts/preflight.js; echo "EXIT=$?"{"content":[{"text":"node:internal/modules/cjs/loader:1433\n throw err;\n ^\n\nError: Cannot find module './client'\nRequire stack:\n- /sandbox/repo/scripts/p…ctrl+o
{"content":[{"text":"node:internal/modules/cjs/loader:1433\n  throw err;\n  ^\n\nError: Cannot find module './client'\nRequire stack:\n- /sandbox/repo/scripts/preflight.js\n    at Function._resolveFilename (node:internal/modules/cjs/loader:1430:15)\n    at defaultResolveImpl (node:internal/modules/cjs/loader:1040:19)\n    at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1045:22)\n    at Function._load (node:internal/modules/cjs/loader:1216:25)\n    at wrapModuleLoad (node:internal/modules/cjs/loader:254:19)\n    at Module.require (node:internal/modules/cjs/loader:1527:12)\n    at require (node:internal/modules/helpers:147:16)\n    at Object.<anonymous> (/sandbox/repo/scripts/preflight.js:3:26)\n    at Module._compile (node:internal/modules/cjs/loader:1781:14)\n    at Object..js (node:internal/modules/cjs/loader:1913:10) {\n  code: 'MODULE_NOT_FOUND',\n  requireStack: [ '/sandbox/repo/scripts/preflight.js' ]\n}\n\nNode.js v22.23.2\nEXIT=1\n","type":"text"}]}
$ cd /sandbox/repo && node -e "{"content":[{"text":"sdk helper: e6c6e088f608aa247362aa81c0a1b2733ff5c7a513001ac3ea809f4c8c24968b\nmatches documented HMAC-SHA256 scheme: true\n","type":"text"}…ctrl+o
cd /sandbox/repo && node -e "
const {hashUserId}=require('./src/client');
const h=hashUserId('test-key','user-123');
const c=require('crypto').createHmac('sha256',Buffer.from('test-key','utf-8')).update('user-123').digest('hex');
console.log('sdk helper:',h);
console.log('matches documented HMAC-SHA256 scheme:', h===c);
"

{"content":[{"text":"sdk helper: e6c6e088f608aa247362aa81c0a1b2733ff5c7a513001ac3ea809f4c8c24968b\nmatches documented HMAC-SHA256 scheme: true\n","type":"text"}]}
Thinking…

Fix the require paths in preflight.js — it's in scripts/, so needs ../src/client.

edit /sandbox/repo/scripts/preflight.js{"content":[{"text":"Successfully replaced 1 block(s) in /sandbox/repo/scripts/preflight.js.","type":"text"}],"details":{"diff":" 1 'use strict';\n 2 \n- 3 co…ctrl+o
{"content":[{"text":"Successfully replaced 1 block(s) in /sandbox/repo/scripts/preflight.js.","type":"text"}],"details":{"diff":"  1 'use strict';\n  2 \n- 3 const { createClient } = require('./client');\n- 4 const { loadEnv, required } = require('./env');\n+ 3 const { createClient } = require('../src/client');\n+ 4 const { loadEnv, required } = require('../src/env');\n  5 \n  6 /**\n  7  * Preflight check: validate credentials and reachability WITHOUT spending tasks.\n  8  *\n    ...","patch":"--- /sandbox/repo/scripts/preflight.js\n+++ /sandbox/repo/scripts/preflight.js\n@@ -1,8 +1,8 @@\n 'use strict';\n \n-const { createClient } = require('./client');\n-const { loadEnv, required } = require('./env');\n+const { createClient } = require('../src/client');\n+const { loadEnv, required } = require('../src/env');\n \n /**\n  * Preflight check: validate credentials and reachability WITHOUT spending tasks.\n  *\n","firstChangedLine":3}}
Thinking…

Also note: env.js resolves .env relative to process.cwd(). Since npm scripts run from repo root, that's fine.

Run preflight again.

$ cd /sandbox/repo && AIROPS_WORKSPACE_ID=1 AIROPS_API_KEY=placeholder-key AIROPS_USER_ID=demo-user-1 AIROPS_APP_ID=1 timeout 60 node scripts/preflight.js; echo "EXIT=$?"; echo "=== runApp with dummy cr…{"content":[{"text":"Checking app 1 ...\nFAILED (HTTP 401): Not Authorized\n\nAuth problem. Check, in order:\n 1. AIROPS_API_KEY is the workspace API key (Sett…ctrl+o
cd /sandbox/repo && AIROPS_WORKSPACE_ID=1 AIROPS_API_KEY=placeholder-key AIROPS_USER_ID=demo-user-1 AIROPS_APP_ID=1 timeout 60 node scripts/preflight.js; echo "EXIT=$?"; echo "=== runApp with dummy creds ==="; AIROPS_WORKSPACE_ID=1 AIROPS_API_KEY=placeholder-key AIROPS_APP_ID=1 AIROPS_APP_INPUTS='{"topic":"devtools"}' AIROPS_DEADLINE_MS=8000 timeout 60 node src/runApp.js; echo "EXIT=$?"

{"content":[{"text":"Checking app 1 ...\nFAILED (HTTP 401): Not Authorized\n\nAuth problem. Check, in order:\n  1. AIROPS_API_KEY is the workspace API key (Settings -> Workspace -> API KEY)\n  2. AIROPS_WORKSPACE_ID matches the workspace that key belongs to\n  3. The key has not been rotated since you copied it\nEXIT=1\n=== runApp with dummy creds ===\nExecuting app 1 with inputs: { topic: 'devtools' }\nCould not start execution (HTTP 401): Not Authorized\nRun `npm run preflight` to diagnose credentials.\nEXIT=1\n","type":"text"}]}
Thinking…

The code works end-to-end: it constructs the auth correctly, reaches the live AirOps API, and gets 401 Not Authorized because there's no real workspace key. That's the honest blocker.

Let me also verify the "missing env" path gives a good message, and test the .env loading. Then write the README with pricing + manageability assessment.

Let me quickly test missing-env UX.

$ cd /sandbox/repo && env -u AIROPS_APP_ID timeout 30 node scripts/preflight.js; echo "EXIT=$?"; echo "=== bad JSON inputs ==="; AIROPS_WORKSPACE_ID=1 AIROPS_API_KEY=k AIROPS_APP_ID=1 AIROPS_APP_INPUTS=…{"content":[{"text":"Missing AIROPS_APP_ID. Get it from the app builder URL: .../apps/<APP_ID>\nCopy .env.example to .env and fill in your workspace credentials…ctrl+o
cd /sandbox/repo && env -u AIROPS_APP_ID timeout 30 node scripts/preflight.js; echo "EXIT=$?"; echo "=== bad JSON inputs ==="; AIROPS_WORKSPACE_ID=1 AIROPS_API_KEY=k AIROPS_APP_ID=1 AIROPS_APP_INPUTS='not-json' timeout 30 node src/runApp.js; echo "EXIT=$?"

{"content":[{"text":"Missing AIROPS_APP_ID. Get it from the app builder URL: .../apps/<APP_ID>\nCopy .env.example to .env and fill in your workspace credentials.\nEXIT=1\n=== bad JSON inputs ===\nAIROPS_APP_INPUTS is not valid JSON (Unexpected token 'o', \"not-json\" is not valid JSON).\nEXIT=1\n","type":"text"}]}
Thinking…

Error UX works. Now the README with:

  • what this is
  • setup
  • pricing explanation
  • manageability assessment
  • the blocker

Let me also double-check a couple of pricing facts I want to state accurately. From the pricing page:

  • Tiers: Solo, Pro, Enterprise (page also mentions "Scale" and "Agency" in FAQ — legacy names).
  • Solo: 35,000 tasks for content production, 1 Brand Kit, 3 Knowledge Bases, single-user, ChatGPT insights only.
  • Pro: 100,000 tasks, insights across 7+ answer engines, 1 Brand Kit, 5 KBs, unlimited seats.
  • Enterprise: custom task limits, unlimited KBs & brand kits, dedicated AM, SSO, BYO model keys, etc.
  • Currency = "tasks"; only certain step types count; depends on step type and data processed.
  • Overage: Solo additional tasks $0.025/task; resets monthly at start of calendar month.
  • Free trial: 14 days, all Scale features, no payment details until end; each workspace gets 50,000 free credits for Scale/Enterprise/14-day trial.
  • Track usage: Settings > Billing; workflow-level via Monitoring.
  • Dollar prices for Solo/Pro are NOT shown on the page (it says "Start for free" and "Work with us"). Let me verify whether dollar amounts appear anywhere in the page HTML — the text extraction showed no $ amounts except $0.025. Let me grep the raw HTML for "$" to be sure.

Also worth noting: no public pricing numbers → pricing is effectively sales-gated for Pro/Enterprise. Let me confirm with a grep.

Also let me check whether API/SDK access is gated by plan — the pricing table listed "AirOps MCP" and "MCP Connectors" as integration rows. It didn't explicitly list "API access". Let me grep the comparison table for "API".

$ cd /tmp && grep -oE '\$[0-9][0-9,]*(\.[0-9]+)?' pricing.html | sort -u | head -20; echo "--- per month / mo ---"; grep -oiE '(per month|/month|monthly|per seat)' pricing.html | sort | uniq -c | head; …{"content":[{"text":"$0.025\n--- per month / mo ---\n 2 /month\n 2 monthly\n--- API mentions ---\n... opacity:1; transform:none; trans…ctrl+o
cd /tmp && grep -oE '\$[0-9][0-9,]*(\.[0-9]+)?' pricing.html | sort -u | head -20; echo "--- per month / mo ---"; grep -oiE '(per month|/month|monthly|per seat)' pricing.html | sort | uniq -c | head; echo "--- API mentions ---"; python3 - <<'EOF'
import re,html
t=open('/tmp/pricing.html',encoding='utf-8',errors='ignore').read()
t=re.sub(r'<script[^>]*>.*?</script>','',t,flags=re.S|re.I)
t=re.sub(r'<[^>]+>','|',t); t=html.unescape(t)
for m in re.finditer(r'API|SDK|MCP', t):
    seg=t[max(0,m.start()-90):m.start()+90].replace('\n',' ')
    seg=re.sub(r'\|+','|',seg)
    print('...',seg.strip())
EOF

{"content":[{"text":"$0.025\n--- per month / mo ---\n      2 /month\n      2 monthly\n--- API mentions ---\n... opacity:1;         transform:none;         transition:none;       }     }     /* Platform MCP */   .platform_mcp-dd-toggle:hover{     .btn-text{       opacity: 0.5;     }     .icon\n... SEO Research|AEO Research|Project Management|Company Context|Social|AirOps MCP|MCP Connectors|\n... earch|AEO Research|Project Management|Company Context|Social|AirOps MCP|MCP Connectors|\n... |Organization|Users|SSO|Bring your own Model API Keys|Support Tier|Forward Deployed Engineer|1|Community &|Li\n... ge at the workflow level via Monitoring from the home screen.|Can I use my own API keys with AirOps?|Yes! If you are on a Solo plan, the ability t\n","type":"text"}]}
Thinking…

Confirmed: no dollar plan prices published; the only $ figure is $0.025/task overage on Solo. "AirOps MCP" and "MCP Connectors" appear as integration comparison rows, but the extracted text didn't show which tiers get them (the values are in the table cells; extraction collapsed them). Let me try to extract the comparison table rows with tier values to see if MCP/API is gated. It may be hard. Let me try a targeted extraction of the feature table.

Actually, for the purposes of the answer, precision on which tier unlocks MCP is a nice-to-have. Let me make one attempt to parse the table properly.

$ cd /tmp && python3 - <<'EOF'{"content":[{"text":"rows found: 0\n","type":"text"}]}ctrl+o
cd /tmp && python3 - <<'EOF'
import re,html
t=open('/tmp/pricing.html',encoding='utf-8',errors='ignore').read()
t=re.sub(r'<script[^>]*>.*?</script>','',t,flags=re.S|re.I)
t=re.sub(r'<style[^>]*>.*?</style>','',t,flags=re.S|re.I)
# find table region
i=t.find('Comparison'); 
rows=re.findall(r'<tr[^>]*>(.*?)</tr>', t, flags=re.S|re.I)
print("rows found:", len(rows))
for r in rows[:60]:
    cells=[re.sub(r'\s+',' ',html.unescape(re.sub(r'<[^>]+>',' ',c))).strip() for c in re.findall(r'<t[dh][^>]*>(.*?)</t[dh]>', r, flags=re.S|re.I)]
    cells=[c for c in cells if c]
    if cells: print(' | '.join(cells))
EOF

Thinking…

No <table> markup — it's likely a Webflow div grid. Not worth more effort; I have enough. The tier values I saw in the flat text were: "35,000 1 3" (Solo: tasks 35,000, brand kits 1, KBs 3) and "100,000 1 5" (Pro) and "Custom Unlimited Unlimited" (Enterprise). And "Organization Users SSO Bring your own Model API Keys Support Tier Forward Deployed Engineer 1 ..." — the values weren't clearly attributable.

Let me do one focused attempt: find the div-based grid near "AirOps MCP" and print surrounding text with tier columns. Try extracting a window of ~1200 chars around "AirOps MCP" occurrence #2 (the table one).

$ cd /tmp && python3 - <<'EOF'{"content":[{"text":"occurrences: 1\n","type":"text"}]}ctrl+o
cd /tmp && python3 - <<'EOF'
import re,html
t=open('/tmp/pricing.html',encoding='utf-8',errors='ignore').read()
t=re.sub(r'<script[^>]*>.*?</script>','',t,flags=re.S|re.I)
t=re.sub(r'<style[^>]*>.*?</style>','',t,flags=re.S|re.I)
plain=re.sub(r'<[^>]+>','|',t); plain=html.unescape(plain); plain=re.sub(r'\|+','|',plain); plain=re.sub(r'\s+',' ',plain)
idx=[m.start() for m in re.finditer('AirOps MCP',plain)]
print("occurrences:",len(idx))
if len(idx)>=2:
    print(plain[idx[-1]-200:idx[-1]+1500])
EOF

Thinking…

Only one occurrence in plain text now (the earlier one was in CSS comment). So the comparison-table listing "AirOps MCP|MCP Connectors" was from the earlier extraction where style wasn't fully removed... Actually in the first extraction I removed style too. Hmm, earlier output showed "Project Management|Company Context|Social|AirOps MCP|MCP Connectors|" — that was with <[^>]+>| and style removed. Now occurrences=1 because I collapse \|+ and whitespace... no, that shouldn't remove it. Possibly the second extraction removed style blocks that contained the text? Whatever.

The row labels exist but tier values aren't reliably attributable. I'll state MCP availability as "listed in the integration comparison, tier gating not published in machine-readable form" — or simply not claim it. Better: mention that the pricing page doesn't publish per-tier API/MCP gating in a way I could verify, and that plan prices aren't public.

Now write the README. Then give the final answer with:

  1. What I built + how to run it
  2. Pricing
  3. Manageability assessment
  4. The blocker

Let me write the README.

write /sandbox/repo/README.md{"content":[{"text":"Successfully wrote 5166 bytes to /sandbox/repo/README.md","type":"text"}]}
Thinking…

Let me verify the cancel endpoint method claim (PATCH) — I saw patch in CustomFetch and endpoint /sdk_api/airops_apps/executions/${executionId}/cancel. Let me confirm cancelExecution uses patch. Quick grep.

Also verify final repo state and run a last sanity check.

$ cd /tmp/probe/node_modules/@airops/airops-js && node -e "{"content":[{"text":"cancelExecution({\n executionId: response.airops_app_execution.uuid,\n });\n }),\n…ctrl+o
cd /tmp/probe/node_modules/@airops/airops-js && node -e "
const s=require('fs').readFileSync('dist/index.cjs.js','utf8');
const i=s.indexOf('cancelExecution(');
console.log(s.slice(i,i+400));
"

{"content":[{"text":"cancelExecution({\n                        executionId: response.airops_app_execution.uuid,\n                    });\n                }),\n            };\n        });\n    }\n    /**\n     * Chat stream\n     * @param params\n     * @param params.message - Message to send\n     * @param params.appId - App ID\n     * @param params.sessionId - Session ID\n     * @param params.streamCallback - Callback function f\n","type":"text"}]}
$ cd /sandbox/repo && echo "=== tree ==="; find . -not -path './node_modules*' -not -path './.git/*' -not -name '.git' | sort; echo; echo "=== git status ==="; git status --short; echo; echo "=== confir…{"content":[{"text":"=== tree ===\n.\n./.env.example\n./.gitignore\n./README.md\n./package-lock.json\n./package.json\n./scripts\n./scripts/preflight.js\n./src\n…ctrl+o
cd /sandbox/repo && echo "=== tree ==="; find . -not -path './node_modules*' -not -path './.git/*' -not -name '.git' | sort; echo; echo "=== git status ==="; git status --short; echo; echo "=== confirm .env ignored ==="; echo "AIROPS_API_KEY=secret" > .env && git check-ignore -v .env && rm .env

{"content":[{"text":"=== tree ===\n.\n./.env.example\n./.gitignore\n./README.md\n./package-lock.json\n./package.json\n./scripts\n./scripts/preflight.js\n./src\n./src/browserShim.js\n./src/client.js\n./src/env.js\n./src/runApp.js\n\n=== git status ===\n M .gitignore\n?? .env.example\n?? README.md\n?? package-lock.json\n?? package.json\n?? scripts/\n?? src/\n\n=== confirm .env ignored ===\n.gitignore:8:.env\t.env\n","type":"text"}]}
Thinking…

Verify the private cancelExecution implementation uses patch. Let me grep the class method definition (second occurrence).

$ cd /tmp/probe/node_modules/@airops/airops-js && node -e "{"content":[{"text":"l = `${this.host}/sdk_api/airops_apps/executions/${executionId}`;\n return yield this.customFetch.get(url);\n });\n }\…ctrl+o
cd /tmp/probe/node_modules/@airops/airops-js && node -e "
const s=require('fs').readFileSync('dist/index.cjs.js','utf8');
const i=s.lastIndexOf('cancel');
const j=s.indexOf('/cancel');
console.log(s.slice(j-500,j+300));
"

{"content":[{"text":"l = `${this.host}/sdk_api/airops_apps/executions/${executionId}`;\n            return yield this.customFetch.get(url);\n        });\n    }\n    /**\n     * Cancel execution\n     * @param params\n     * @param params.appId - App ID\n     * @param params.executionId - Execution ID\n     */\n    cancelExecution(params) {\n        return __awaiter(this, void 0, void 0, function* () {\n            const { executionId } = params;\n            const url = `${this.host}/sdk_api/airops_apps/executions/${executionId}/cancel`;\n            yield this.customFetch.patch(url);\n        });\n    }\n    pollExecutionResult(response) {\n        return __awaiter(this, void 0, void 0, function* () {\n            const timeout = 10 * 60 * 1000; // Timeout in milliseconds (10 minutes)\n            const startTime = Date.now();\n \n","type":"text"}]}
Thinking…

All confirmed. README's endpoint list is accurate (PATCH cancel).

Everything is verified. Final answer:

  1. What I built (files, how to run, what I verified)
  2. Pricing
  3. Manageability assessment — brief
  4. Blocker: no workspace credentials → 401. Need human to create account and get API key. Also flag the Python SDK/Docker quickstart as the thing I refused to start per their constraint.

Keep it concise but complete.

Note: AirOps DOES have a developer workflow I can act on, so I shouldn't stop — but I am blocked on credentials. Be plain about that.

Let me write the final response.

Done — example built and verified against the live API. Files in /sandbox/repo:

README.md            package.json         .env.example   (.env is gitignored)
src/browserShim.js   src/env.js           src/client.js  src/runApp.js
scripts/preflight.js

What it does: executes a hosted AirOps app via the official @airops/airops-js SDK — pure HTTPS to app.airops.com. No Docker, no local services, nothing long-running.

Run it:

npm install && cp .env.example .env   # add workspace id, API key, app id
npm run preflight                     # validates creds, costs 0 tasks
AIROPS_APP_INPUTS='{"topic":"devtools"}' npm start

Verified: all files pass node --check; the HMAC-SHA256 hashed-user-id helper matches AirOps' documented scheme byte-for-byte; both scripts reach the live API and handle missing-config, bad-JSON, 401, and 404 paths with actionable messages.

Three gotchas I had to work around (documented in the README):

  • The SDK bundles pusher-js's browser build — it throws ReferenceError: window is not defined in plain Node. browserShim.js installs minimal DOM stubs.
  • Streaming needs a real DOM, so the example uses execute + HTTP polling instead.
  • The SDK's built-in result() polls for 10 minutes. I replaced it with a bounded loop (60s default) that prints the executionId so you can resume rather than re-run and spend more tasks.

Pricing

  • Billing unit is a task, not a seat or request. Only certain workflow step types count, and the count varies by step type and data volume. You pay only for tasks actually run.
  • Tiers: Solo (35k tasks/mo, 1 brand kit, 3 KBs, single user) · Pro (100k tasks/mo, 7+ answer engines, 5 KBs, unlimited seats) · Enterprise (custom limits, unlimited KBs/brand kits, SSO, BYO model keys).
  • No dollar plan prices are published. The only public number is Solo overage at $0.025/task. Enterprise is "Work with us."
  • Trial: 14 days, all Scale features, no card up front; Scale/Enterprise/trial workspaces get 50,000 free credits before task billing starts. Tasks reset monthly; usage at Settings → Billing.

Will this be easy for me to manage?

Partly. The JS SDK/HTTP surface is clean and fully scriptable — good error types, stable REST endpoints, an MCP for step discovery. Two real frictions: (1) the SDK is browser-targeted and needs shimming for server-side use, and (2) opaque cost feedback — task consumption isn't exposed as a machine-readable quota/usage field in the execution response, so I can't programmatically budget or detect I'm about to burn through an allotment. Overage is silently billed at $0.025/task. I'd want a usage endpoint before letting me run workflows unattended.

Blocked

Yes — on credentials. AirOps does have a developer workflow I can act on, so I didn't stop, but I can't execute anything real:

  • No AirOps credentials exist in this environment (no workspace id, API key, or token).
  • I confirmed the API is live and my auth construction is correct — it returns 401 {"error":"Not Authorized"} with placeholder credentials, which is the expected response, not a wiring bug.
  • I probed for public apps (the SDK supports unauthenticated execution of public apps) — all sdk_api/airops_app_bases/{id} lookups return 404. There's no public demo app to run against.
  • Getting a key requires a human: create an account at app.airops.com, then Settings → Workspace → API KEY. I can't sign up on your behalf.

One thing I deliberately did not do: the Python SDK quickstart (airops init / run / publish) requires Docker plus a multi-minute image build and upload — a local service stack and a long-running command. Per your constraints I skipped it and used the hosted HTTP API instead. Say the word if you want that path anyway.

Give me a workspace id, API key, and app id and I'll finish the loop end-to-end.

AX Fundamentals

23 checks. Open an area for details.

This check is a quick preview. Test more tasks and models with Gauge Agents.

Test your product with Gauge Agents

AX Check by Gauge