ADR-0011: Anthropic Skills API Integration

View Source

Status

Proposed

Context

Note: This ADR replaces an earlier incorrect design that assumed a "pass-through executor" pattern. The original design was based on a misunderstanding of how Anthropic's code execution capabilities work. This revision accurately describes the actual Anthropic Skills API.

Anthropic provides a Skills API (beta) that allows:

  1. Uploading custom skills to Anthropic's infrastructure
  2. Using pre-built Anthropic skills (xlsx, pptx, docx, pdf)
  3. Executing skills in Anthropic-managed containers with code execution

This provides an alternative to local/Docker execution for users who:

  • Cannot run Docker in their environment
  • Want Anthropic to manage sandbox security
  • Need access to Anthropic's pre-built document skills
  • Prefer hosted execution without local infrastructure

How the Skills API Actually Works

1. Upload skill files  POST /v1/skills  receive skill_id
2. Include skill_id in container.skills parameter
3. Enable code_execution_20250825 tool in the request
4. Anthropic's container loads skills at /skills/{directory}/
5. Claude uses code execution to run code with skill files available
6. Download created files via Files API

Key Differences from Original Design

Original (Incorrect)Actual API
Pass-through for bash commandsSkills uploaded first, get skill_id
{:passthrough, config} returnUses container.skills parameter
Executor behaviour implementationNot an executor—API integration helpers
Local skill filesSkills must be uploaded to Anthropic

Beta Requirements

The Skills API requires beta headers:

  • code-execution-2025-08-25 - Enables code execution
  • skills-2025-10-02 - Enables Skills API
  • files-api-2025-04-14 - For uploading/downloading files

Skill Types

TypeDescriptionID Format
anthropicPre-built by AnthropicShort names: xlsx, pptx, docx, pdf
customUser-uploadedGenerated: skill_01AbCdEfGhIjKlMnOpQrStUv

Decision

We will provide optional Anthropic Skills API integration as helper modules, NOT as an executor implementation. This is fundamentally different from local/Docker execution because:

  1. Conjure does NOT execute tools—Anthropic's container does
  2. Skills must be uploaded to Anthropic first
  3. The integration is at the API request level, not execution level

Note: While this ADR describes the Anthropic-specific modules, ADR-0019: Unified Execution Model describes how these modules are surfaced through a unified Conjure.Session API that provides identical interaction patterns for both local/Docker and Anthropic execution.

Module Design

defmodule Conjure.Skills.Anthropic do
  @moduledoc """
  Upload and manage skills via Anthropic Skills API.

  This module provides helpers for interacting with Anthropic's
  Skills API to upload custom skills and manage versions.

  Note: This is NOT an executor. Skills uploaded here are executed
  by Anthropic's infrastructure, not by Conjure.
  """

  @doc """
  Upload a skill directory to Anthropic.

  Returns the skill_id for use in API requests.

  ## Example

      {:ok, skill_id} = Conjure.Skills.Anthropic.upload(
        "priv/skills/csv-helper",
        display_title: "CSV Helper",
        api_key: System.get_env("ANTHROPIC_API_KEY")
      )

  """
  @spec upload(Path.t(), keyword()) :: {:ok, String.t()} | {:error, term()}
  def upload(skill_path, opts \\ [])

  @doc """
  List skills available in your Anthropic workspace.

  ## Options

    * `:source` - Filter by "anthropic" or "custom"
    * `:api_key` - Anthropic API key

  """
  @spec list(keyword()) :: {:ok, [map()]} | {:error, term()}
  def list(opts \\ [])

  @doc """
  Delete a custom skill from Anthropic.

  Note: All versions must be deleted first.
  """
  @spec delete(String.t(), keyword()) :: :ok | {:error, term()}
  def delete(skill_id, opts \\ [])

  @doc """
  Create a new version of an existing skill.
  """
  @spec create_version(String.t(), Path.t(), keyword()) :: {:ok, String.t()} | {:error, term()}
  def create_version(skill_id, skill_path, opts \\ [])
end

API Request Helpers

defmodule Conjure.API.Anthropic do
  @moduledoc """
  Helpers for building Anthropic API requests with Skills.
  """

  @doc """
  Build the container parameter for skills.

  ## Example

      container = Conjure.API.Anthropic.container_config([
        {:anthropic, "xlsx", "latest"},
        {:anthropic, "pptx", "latest"},
        {:custom, "skill_01AbCdEfGhIjKlMnOpQrStUv", "latest"}
      ])

      # Returns:
      # %{
      #   "skills" => [
      #     %{"type" => "anthropic", "skill_id" => "xlsx", "version" => "latest"},
      #     %{"type" => "anthropic", "skill_id" => "pptx", "version" => "latest"},
      #     %{"type" => "custom", "skill_id" => "skill_01...", "version" => "latest"}
      #   ]
      # }

  """
  @spec container_config([skill_spec()]) :: map()
  def container_config(skills)

  @type skill_spec ::
    {:anthropic, String.t(), String.t()} |
    {:custom, String.t(), String.t()}

  @doc """
  Get the required beta headers for Skills API.
  """
  @spec beta_headers() :: [{String.t(), String.t()}]
  def beta_headers do
    [
      {"anthropic-beta", "code-execution-2025-08-25,skills-2025-10-02,files-api-2025-04-14"}
    ]
  end

  @doc """
  Get the code execution tool definition.
  """
  @spec code_execution_tool() :: map()
  def code_execution_tool do
    %{
      "type" => "code_execution_20250825",
      "name" => "code_execution"
    }
  end
end

Multi-Skill Support

The Skills API supports up to 8 skills per request. Skills can be combined for complex workflows (e.g., analyze data with Excel skill, create presentation with PowerPoint skill):

container = Conjure.API.Anthropic.container_config([
  {:anthropic, "xlsx", "latest"},
  {:anthropic, "pptx", "latest"},
  {:anthropic, "pdf", "latest"},
  {:custom, "skill_01AbCdEfGhIjKlMnOpQrStUv", "latest"}
])

Long-Running Operations

Skills may perform operations that require multiple turns. The API returns a pause_turn stop reason when an operation is paused, requiring the client to continue the conversation:

defmodule Conjure.Conversation.Anthropic do
  @moduledoc """
  Conversation loop for Anthropic Skills API with pause_turn handling.
  """

  @doc """
  Run a conversation with Anthropic-hosted skills, handling pause_turn.

  Unlike local/Docker execution where Conjure manages tool execution,
  here Anthropic executes in their container. However, long-running
  operations still require a conversation loop to handle pause_turn.

  ## Options

    * `:max_retries` - Maximum pause_turn iterations (default: 10)
    * `:on_pause` - Callback when pause_turn received

  """
  @spec run(list(), map(), keyword()) :: {:ok, map()} | {:error, term()}
  def run(messages, container_config, opts \\ []) do
    max_retries = Keyword.get(opts, :max_retries, 10)
    do_run(messages, container_config, opts, 0, max_retries)
  end

  defp do_run(messages, container_config, opts, attempt, max_retries)
       when attempt < max_retries do
    case call_api(messages, container_config, opts) do
      {:ok, %{"stop_reason" => "pause_turn", "content" => content} = response} ->
        # Long-running operation paused - continue with same container
        container_id = get_in(response, ["container", "id"])
        updated_messages = messages ++ [%{role: "assistant", content: content}]
        updated_container = Map.put(container_config, "id", container_id)

        if callback = opts[:on_pause] do
          callback.(response, attempt + 1)
        end

        do_run(updated_messages, updated_container, opts, attempt + 1, max_retries)

      {:ok, response} ->
        {:ok, response}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp do_run(_messages, _container, _opts, attempt, max_retries) do
    {:error, {:max_retries_exceeded, attempt, max_retries}}
  end
end

Multi-Turn Conversations

Reuse the same container across multiple user messages by preserving the container ID:

defmodule Conjure.Session.Anthropic do
  @moduledoc """
  Manage multi-turn sessions with Anthropic Skills API.
  """

  defstruct [:container_id, :skills, :messages]

  @doc """
  Start a new session with specified skills.
  """
  def new(skills) do
    %__MODULE__{
      container_id: nil,
      skills: skills,
      messages: []
    }
  end

  @doc """
  Send a message and get response, preserving container state.
  """
  def chat(session, user_message, opts \\ []) do
    messages = session.messages ++ [%{role: "user", content: user_message}]

    container_config = build_container(session)

    case Conjure.Conversation.Anthropic.run(messages, container_config, opts) do
      {:ok, response} ->
        updated_session = %{session |
          container_id: get_in(response, ["container", "id"]),
          messages: messages ++ [%{role: "assistant", content: response["content"]}]
        }
        {:ok, response, updated_session}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp build_container(%{container_id: nil, skills: skills}) do
    Conjure.API.Anthropic.container_config(skills)
  end

  defp build_container(%{container_id: id, skills: skills}) do
    Conjure.API.Anthropic.container_config(skills)
    |> Map.put("id", id)
  end
end

File Handling

Skills that create documents return file_id values. Use the Files API to download:

defmodule Conjure.Files.Anthropic do
  @moduledoc """
  Download files created by Anthropic Skills.
  """

  @doc """
  Extract file IDs from a response.
  """
  @spec extract_file_ids(map()) :: [String.t()]
  def extract_file_ids(response)

  @doc """
  Download a file by ID.
  """
  @spec download(String.t(), keyword()) :: {:ok, binary(), String.t()} | {:error, term()}
  def download(file_id, opts \\ [])

  @doc """
  Get file metadata.
  """
  @spec metadata(String.t(), keyword()) :: {:ok, map()} | {:error, term()}
  def metadata(file_id, opts \\ [])
end

Usage Example

defmodule MyApp.AnthropicSkillChat do
  @moduledoc """
  Example: Using Anthropic's hosted skills with full conversation support.
  """

  alias Conjure.Session.Anthropic, as: Session
  alias Conjure.Conversation.Anthropic, as: Conversation

  def chat_with_hosted_skills(user_message) do
    # Configure skills (up to 8)
    skills = [
      {:anthropic, "xlsx", "latest"},
      {:anthropic, "pptx", "latest"}
    ]

    # Start session
    session = Session.new(skills)

    # Chat with pause_turn handling for long-running operations
    case Session.chat(session, user_message, on_pause: &log_pause/2) do
      {:ok, response, updated_session} ->
        # Download any created files
        file_ids = Conjure.Files.Anthropic.extract_file_ids(response)
        files = Enum.map(file_ids, &download_file/1)

        {:ok, response, files, updated_session}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp log_pause(response, attempt) do
    IO.puts("Operation paused (attempt #{attempt}), continuing...")
  end

  defp download_file(file_id) do
    {:ok, content, filename} = Conjure.Files.Anthropic.download(file_id)
    File.write!(filename, content)
    filename
  end
end

Comparison: Local/Docker vs Anthropic Hosted

AspectLocal/DockerAnthropic Hosted
Who executesYour applicationAnthropic's container
Skill locationLocal filesystemUploaded to Anthropic
Conversation loopTool call/result looppause_turn handling loop
Multi-turn stateMessage historyContainer ID + messages
Skills per requestUnlimitedUp to 8
Network from sandboxConfigurableNever (isolated)
Pre-built skillsN/Axlsx, pptx, docx, pdf
File outputLocal filesystemFiles API download
CostYour infrastructureAnthropic API pricing

Consequences

Positive

  • No local Docker required - Anthropic manages containers
  • Pre-built document skills - Access xlsx, pptx, docx, pdf without custom implementation
  • Multi-skill workflows - Combine up to 8 skills per request
  • Long-running support - pause_turn handling for complex operations
  • Persistent sessions - Container ID reuse for multi-turn conversations
  • Anthropic-managed security - Sandbox isolation handled by Anthropic
  • Consistent environment - Same execution environment for all users
  • File output support - Files API for downloading created documents

Negative

  • Beta feature - API may change, requires beta headers
  • Skills must be uploaded - Cannot use local skill files directly
  • Network dependency - Requires connectivity to Anthropic API
  • No network from container - Skills cannot make external API calls
  • No runtime package installation - Only pre-installed packages available
  • Upload size limit - 8MB maximum per skill
  • Skills limit - Maximum 8 skills per request
  • Conversation loop required - Must handle pause_turn for long operations

Neutral

  • Different architecture - Not an executor, but API integration
  • Complementary to local - Can use both approaches in same application
  • Version management - Skills have versions, can pin for stability
  • Two loop types - Local uses tool call/result loop, hosted uses pause_turn loop

Alternatives Considered

Executor Behaviour Implementation (Original Design)

The original ADR proposed implementing Conjure.Executor.Anthropic as a behaviour that returns {:passthrough, config}. This was rejected because:

  • Based on incorrect understanding of the API
  • Anthropic doesn't accept arbitrary bash commands
  • Skills must be uploaded first, not passed through
  • The conversation loop model doesn't apply—it's a single request

Transparent Upload on Execute

Automatically upload skills when first used. Rejected because:

  • Requires API key in executor context
  • Upload is a separate concern from execution
  • Better to make upload explicit for version control
  • Skills have IDs that should be managed explicitly

Skip Anthropic Integration Entirely

Only support local/Docker execution. Rejected because:

  • Users may want hosted execution without Docker
  • Pre-built document skills are valuable
  • Anthropic's security expertise for sandboxing
  • Useful for environments where Docker isn't available

References