# Tamarind Custom Tools API guide for agents

Use this guide to package source, build an immutable Custom Tool version, monitor it, and publish
it. The Custom Tools API manages tools and versions; submitting a run is a separate Tamarind Jobs
API operation available as soon as a version is Complete, including before publishing.

## Setup

- Base URL: set `TAMARIND_API_BASE` to the organization-specific API URL shown in the docs. Shared
  users set `https://app.tamarind.bio/api/`; dedicated-tenant users set their tenant host.
- Authentication: send `x-api-key: $TAMARIND_API_KEY` to Tamarind API endpoints.
- Recommended client: `pip install "tamarind-cli>=0.3.0"`
- Raw HTTP example dependency: `pip install requests`
- Keep API keys in environment variables. Never put the key in source archives or send it to a
  presigned upload URL.
- Remove local secret files such as `.env`, `.npmrc`, `.pypirc`, and `.netrc` before packaging a
  source folder, and never include repository metadata such as `.git/`.

## Source and runtime contract

A minimal Python source folder looks like this:

```text
my-custom-tool/
├── Dockerfile
├── config.json
├── main.py
└── run.sh
```

Only `Dockerfile`, `config.json`, and `run.sh` are required. `main.py` is an optional, conventional
entry point for Python tools; other languages and filenames are valid when `run.sh` invokes them.

- The container's working directory is `/app`.
- The orchestrator runs `bash -c "source /shared/env && bash run.sh"`.
- Scalar settings arrive as environment variables. File inputs arrive as absolute paths in their
  environment variables, under `/app/inputs/`.
- Write every durable result under `/app/out/`.
- The runtime container has no network access. Bake packages, model weights, and other dependencies
  into the image, and bake them outside `/app` (for example `/opt/<tool>/`): your uploaded source
  replaces `/app` at runtime, so anything the Dockerfile writes there is missing when the job runs,
  even though it was present at build time. Point at it with an `ENV`. For sequence inputs that need an MSA, declare `usesMsa` in `config.json` so the
  platform stages alignments before the container starts.
- `config.json` is the declarative tool configuration. Validate it against
  <https://app.tamarind.bio/tamarind-tool.schema.json>.
- Start from the maintained template: <https://github.com/Tamarind-Bio/tamarind-custom-tools-template>.

Minimal entry-point files:

```dockerfile
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN chmod +x run.sh
CMD ["bash", "run.sh"]
```

```bash
#!/bin/bash
# run.sh
set -euo pipefail
mkdir -p /app/out
python /app/main.py
```

## Recommended SDK lifecycle

For method signatures, parameters, return values, and exceptions, see the
[Custom Tools Python SDK reference](https://app.tamarind.bio/api-docs/custom-tools-sdk-reference).

Save this as `build_custom_tool.py`, set both `TAMARIND_API_KEY` and `TAMARIND_API_BASE`, and place it
beside the source folder.

```python
import os
import uuid

from tamarind import Tamarind

TOOL_NAME = os.environ.get(
    "TAMARIND_CUSTOM_TOOL_NAME",
    f"my-custom-tool-{uuid.uuid4().hex[:12]}",
)
SOURCE_DIR = "./my-custom-tool"


def show_event(event):
    print(event.message)


with Tamarind(
    api_key=os.environ["TAMARIND_API_KEY"],
    api_base=os.environ["TAMARIND_API_BASE"],
) as client:
    print(f"Creating a new tool named {TOOL_NAME}")
    tool = client.custom_tools.create(
        TOOL_NAME,
        display_name="My Custom Tool",
    )

    report = tool.validate(SOURCE_DIR)
    if not report.valid:
        for problem in report.errors:
            print(f"{problem.path}: {problem.message}")
        raise SystemExit("Fix the source validation errors before building")

    build = tool.build(SOURCE_DIR, source_timeout=180.0)
    print(f"{build.action}: {build.version.version}")

    version = build.version.monitor(
        timeout=1800,
        interval=2.0,
        on_event=show_event,
    )
    if version.status != "Complete":
        raise RuntimeError(f"Build ended with {version.status}: {version.error}")

    published_tool = version.publish()
    print(f"Published {published_tool.name} {published_tool.default_version}")
```

The SDK validates, packages, and uploads your files, then builds a version and checks its status
and logs. `BuildResult.action` is `build`,
`reuse_image`, or `unchanged`; always continue with `BuildResult.version`.

The example generates a unique tool name on each run. Set `TAMARIND_CUSTOM_TOOL_NAME` when you want
to choose it yourself. Creation deliberately fails if that name already exists: do not treat a
conflict as permission to build or publish over another organization member's tool.

SDK resource objects are snapshots. After updating a tool, use the returned object:
`tool = tool.update(description="Updated description")`. After a build, assign `tool = tool.refresh()`
before starting a different build through the same variable. A conflict means the tool changed;
review the latest state rather than automatically retrying over another edit.

## Versions

A version is one build of your tool.

- `version.version` is the version number, such as `v3`. Use the same value to check status, read
  logs, cancel, publish, or pass it as `version` to `/submit-job`.
- Publishing selects a completed version as the default. Publish an older completed version to roll back the default.

## Using your custom tool

Once a build is `Complete`, run it before publishing by specifying its `version.version` as
`version`. Use an API key with access to the tool.

```http
POST /submit-job
x-api-key: YOUR_API_KEY
Content-Type: application/json

{
  "jobName": "my-custom-tool-test",
  "type": "my-custom-tool",
  "version": "v1",
  "settings": { "sequence": "MKTAYIAKQRQISFVKSHFSRQ" }
}
```

Send this request to your API base URL. Set `type` to your tool's name, use a unique `jobName`,
and supply the inputs declared in `config.json` through `settings`. This example assumes a
`sequence` input. `version` takes the completed version (such as `v1`).

Publishing selects the default version. After publishing, omit `version` to run that default,
or keep it to run a specific completed version.

## HTTP reference

The SDK handles the following steps automatically. Direct HTTP changes apply to the current tool
and may overwrite concurrent edits. To protect a state you previously read, see
[Optional concurrency checks](#optional-concurrency-checks).

1. `POST /custom-tools` creates a uniquely named Draft tool. If the name already exists, choose a
   different name. Only fetch and modify an existing tool when the caller has explicitly selected
   that tool and confirmed it is the intended target.
2. Make a ZIP archive.
3. `POST /custom-tools/{name}/uploads` creates a short-lived upload.
4. Send the ZIP using the response's `uploadMethod`, `uploadUrl`, and every `uploadHeaders` entry.
   This object-store request is not a Tamarind API call: do **not** send `x-api-key`.
5. `POST /custom-tools/{name}/build` with `{"uploadId": "<uploadId>"}` starts or reuses a build
   and returns `{action, version}`.
6. Poll `GET /custom-tools/{name}?version=v1` using the `version` returned by the build response.
   Read build events from `GET /custom-tools/{name}/build-logs?version=v1`. Stop when
   `version.terminal` is true.
7. When `version.status` is `Complete`, `POST /custom-tools/{name}/publish?version=v1` to select it
   as the default version. Omit `version` to publish the latest completed version.

Version statuses are `Queued`, `Running`, `Complete`, and `Stopped`. `Complete` and `Stopped` are
terminal. A terminal response can include an `error` with `code` and `message`. Cancel a nonterminal
build with `POST /custom-tools/{name}/cancel-build?version=v1`. If exactly one build is active, you
can omit `version`. Cancellation is a request; keep polling
for the final result.
Build-log cursors are live while a build runs:
carry `nextCursor` into the next status-poll iteration and sleep between polls. A repeated non-null
cursor means “no new logs yet,” not “drain another page immediately.” It becomes null after the
terminal log stream is exhausted.

The upload response's `maxBytes` is the archive limit. Its URL expires at `expiresAt`; create a new
upload if it expires. The server computes the checksum and freezes the bytes used by the build.

### Optional upload verification

To verify the upload matches your local ZIP, include `expectedSourceDigest` formatted as
`sha256:<64 lowercase hex characters>`. A mismatch rejects the build. Without this field, the server
builds the bytes it reads from the upload; replacing the upload before that read changes what is
built. SDK/CLI/MCP continue calculating and supplying this assertion automatically.

## Optional concurrency checks

`If-Match` is optional on every mutation that accepts it. It means: “Only apply this if nothing
has changed since I read it.” SDK/CLI/MCP handle these checks automatically.

For direct HTTP, copy the `etag` field from the Get tool JSON body into `If-Match` when updating,
deleting, building, or publishing. If the tool changed, the request returns `412 Precondition Failed`.
Read the tool again, review the changes, and retry only if the action is still appropriate.
Without this header, changes apply to current state and may overwrite concurrent edits.

For example, using the walkthrough's `api` helper:

```python
observed = api("GET", f"/custom-tools/{TOOL_NAME}")
api(
    "PATCH", f"/custom-tools/{TOOL_NAME}",
    headers={"If-Match": observed.json()["etag"]},
    json={"description": "Updated description"},
)
```

- Upload creation accepts the Tool ETag for an early check; include it again on the build request
  to protect admission.
- `If-Match: *` checks existence without comparing changes. Missing targets return 404 without
  a condition; a missing tool targeted by an explicit condition returns 412.
- An ETag tracks changes to a tool. The version number identifies a build. Reads, logs, and
  cancellation need no condition.

## Retrying build requests

Build requests may include `Idempotency-Key`. Reusing a key for the same source and runtime request
returns the already admitted version instead of starting duplicate work. Reusing it for a different
build request returns `409 Conflict`. This prevents duplicate requests; `If-Match` protects against
concurrent edits.

Without `expectedSourceDigest`, a keyed retry must still have a readable upload with the same bytes:
the server hashes it before checking the existing admission. If it was replaced, the existing key
cannot admit the new source. If it has expired or disappeared, the retry fails at upload validation.
Supplying the original digest lets an already admitted keyed request resolve even after its upload
has expired. Keep the key and the same upload for checksum-free retries; do not reuse a key for new
source. Existing conditional-request conflict handling still applies.

## Reference details

A tool name can be deleted and recreated. Version numbers address builds in the current tool with
that name. SDK/CLI/MCP handle the internal lifetime checks automatically.

Source digests identify archive bytes. Use the version number, such as `v2`, to address a build.

## Agent checklist

Before building:

- Confirm `Dockerfile`, `run.sh`, and `config.json` are at the archive root.
- Validate `config.json` against the published schema.
- Read inputs from environment variables and `/app/inputs/`; write outputs only to `/app/out/`.
- Remove runtime downloads and external API calls.

When a build fails:

- Read all `/logs` pages and the version's structured `error`.
- For `Stopped`, distinguish an explicit cancel from the returned build error.
- For an upload or digest error, create a new upload and hash the exact ZIP bytes sent.
- For a timeout in your monitoring process, fetch the same version again. Client timeout does not
  imply that the server canceled the build.

The generated endpoint reference in the API docs is authoritative for request and response fields.
Publishing selects a completed version as the tool's default. To test before publishing, submit
a job with that completed version's name as `version`.
