Native Elixir Skills: Type-Safe In-Process Execution

View Source

Build skills as Elixir modules that execute directly in the BEAM with full access to your application.

Time: 25 minutes

Prerequisites: Complete Hello World first.

What You'll Build

  1. A pure Elixir Echo skill (simple)
  2. A Log Fetcher skill that calls REST APIs from Elixir (production)
  3. Tests for native skills

Why Native Skills?

AspectLocal SkillsNative Skills
LanguagePython, Bash, etc.Elixir
ExecutionSubprocess/shellDirect function call
OverheadProcess spawnNone
Type SafetyRuntime errorsCompile-time checks
App AccessNoneFull (Ecto, GenServers, etc.)

Use native skills when you need:

  • Direct access to your Elixir application state
  • Faster execution without shell overhead
  • Type-safe, pattern-matched implementations
  • Integration with Ecto, Phoenix, GenServers

The NativeSkill Behaviour

Native skills implement Conjure.NativeSkill:

defmodule Conjure.NativeSkill do
  @callback __skill_info__() :: %{
    name: String.t(),
    description: String.t(),
    allowed_tools: [atom()]
  }

  # Optional callbacks based on allowed_tools
  @callback execute(command :: String.t(), context :: map()) ::
    {:ok, String.t()} | {:error, term()}

  @callback read(path :: String.t(), context :: map(), opts :: keyword()) ::
    {:ok, String.t()} | {:error, term()}

  @callback write(path :: String.t(), content :: String.t(), context :: map()) ::
    {:ok, String.t()} | {:error, term()}

  @callback modify(path :: String.t(), old :: String.t(), new :: String.t(), context :: map()) ::
    {:ok, String.t()} | {:error, term()}
end

Callback Mapping

Claude ToolNative CallbackPurpose
bash_toolexecute/2Run commands/logic
viewread/3Read resources
create_filewrite/3Create resources
str_replacemodify/4Update resources

Step 1: Create a Simple Echo Skill

Create lib/my_app/skills/echo.ex:

defmodule MyApp.Skills.Echo do
  @moduledoc """
  A simple echo skill implemented in pure Elixir.
  """

  @behaviour Conjure.NativeSkill

  @impl true
  def __skill_info__ do
    %{
      name: "echo",
      description: "Echo messages back. Use this to test the native skill system.",
      allowed_tools: [:execute]
    }
  end

  @impl true
  def execute(message, _context) do
    timestamp = DateTime.utc_now() |> DateTime.to_iso8601()
    {:ok, "[#{timestamp}] Echo: #{message}"}
  end
end

Step 2: Use the Echo Skill

# Create a session with native skills
session = Conjure.Session.new_native([MyApp.Skills.Echo])

# Define API callback
api_callback = fn messages ->
  body = %{
    model: "claude-sonnet-4-5-20250929",
    max_tokens: 1024,
    messages: messages,
    tools: Conjure.Backend.Native.tool_definitions([MyApp.Skills.Echo])
  }

  Req.post("https://api.anthropic.com/v1/messages",
    json: body,
    headers: [
      {"x-api-key", System.get_env("ANTHROPIC_API_KEY")},
      {"anthropic-version", "2023-06-01"}
    ]
  )
  |> case do
    {:ok, %{status: 200, body: body}} -> {:ok, body}
    {:ok, %{body: body}} -> {:error, body}
    {:error, reason} -> {:error, reason}
  end
end

# Chat
{:ok, response, _session} = Conjure.Session.chat(
  session,
  "Please echo 'Hello from native Elixir!'",
  api_callback
)

Step 3: Create a Log Fetcher Skill

A production skill that fetches logs directly from Elixir:

Create lib/my_app/skills/log_fetcher.ex:

defmodule MyApp.Skills.LogFetcher do
  @moduledoc """
  Fetch logs from REST APIs using native Elixir HTTP client.
  """

  @behaviour Conjure.NativeSkill

  @impl true
  def __skill_info__ do
    %{
      name: "log-fetcher",
      description: """
      Fetch logs from monitoring APIs. Use this skill when you need to:
      - Retrieve logs from a REST endpoint
      - Filter logs by level or time range
      - Get log statistics
      """,
      allowed_tools: [:execute, :read]
    }
  end

  @impl true
  def execute(command, context) do
    case parse_command(command) do
      {:fetch, endpoint, opts} ->
        fetch_logs(endpoint, opts)

      {:stats, endpoint} ->
        get_stats(endpoint)

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

  @impl true
  def read(path, _context, opts) do
    # "read" can list available endpoints or show help
    case path do
      "endpoints" ->
        {:ok, "Available endpoints:\n- /api/logs\n- /api/logs/stats\n- /api/logs/:id"}

      "help" ->
        {:ok, help_text()}

      _ ->
        {:error, "Unknown path: #{path}. Try 'endpoints' or 'help'."}
    end
  end

  # Command parsing

  defp parse_command(command) do
    cond do
      String.starts_with?(command, "fetch ") ->
        parse_fetch_command(command)

      String.starts_with?(command, "stats ") ->
        [_, endpoint] = String.split(command, " ", parts: 2)
        {:stats, String.trim(endpoint)}

      true ->
        {:error, "Unknown command. Use 'fetch <url>' or 'stats <url>'."}
    end
  end

  defp parse_fetch_command(command) do
    parts = String.split(command, " ")
    endpoint = Enum.at(parts, 1)

    opts = parts
    |> Enum.drop(2)
    |> Enum.chunk_every(2)
    |> Enum.reduce([], fn
      ["--limit", n], acc -> [{:limit, String.to_integer(n)} | acc]
      ["--level", level], acc -> [{:level, level} | acc]
      _, acc -> acc
    end)

    {:fetch, endpoint, opts}
  end

  # API calls

  defp fetch_logs(endpoint, opts) do
    limit = Keyword.get(opts, :limit, 100)
    level = Keyword.get(opts, :level)

    query = [limit: limit]
    query = if level, do: [{:level, level} | query], else: query

    case Req.get(endpoint, params: query) do
      {:ok, %{status: 200, body: body}} ->
        {:ok, Jason.encode!(body, pretty: true)}

      {:ok, %{status: status, body: body}} ->
        {:error, "API error #{status}: #{inspect(body)}"}

      {:error, reason} ->
        {:error, "Request failed: #{inspect(reason)}"}
    end
  end

  defp get_stats(endpoint) do
    stats_url = endpoint <> "/stats"

    case Req.get(stats_url) do
      {:ok, %{status: 200, body: body}} ->
        {:ok, Jason.encode!(body, pretty: true)}

      {:ok, %{status: status}} ->
        {:error, "Stats API returned #{status}"}

      {:error, reason} ->
        {:error, "Failed to get stats: #{inspect(reason)}"}
    end
  end

  defp help_text do
    """
    Log Fetcher Commands:

    fetch <url> [--limit N] [--level LEVEL]
      Fetch logs from the specified URL.
      --limit N     Maximum logs to fetch (default: 100)
      --level LEVEL Filter by log level (DEBUG, INFO, WARN, ERROR)

    stats <url>
      Get log statistics from the endpoint.

    Examples:
      fetch http://monitoring.example.com/api/logs --limit 50
      fetch http://monitoring.example.com/api/logs --level ERROR
      stats http://monitoring.example.com/api/logs
    """
  end
end

Step 4: Use the Log Fetcher

session = Conjure.Session.new_native([MyApp.Skills.LogFetcher])

{:ok, response, _} = Conjure.Session.chat(
  session,
  "Fetch the last 50 error logs from http://monitoring.example.com/api/logs",
  &api_callback/1
)

Step 5: Test Native Skills

Create test/my_app/skills/echo_test.exs:

defmodule MyApp.Skills.EchoTest do
  use ExUnit.Case

  alias MyApp.Skills.Echo

  describe "__skill_info__/0" do
    test "returns valid skill info" do
      info = Echo.__skill_info__()

      assert info.name == "echo"
      assert is_binary(info.description)
      assert :execute in info.allowed_tools
    end
  end

  describe "execute/2" do
    test "echoes message with timestamp" do
      {:ok, result} = Echo.execute("Hello", %{})

      assert result =~ "Echo: Hello"
      assert result =~ ~r/\d{4}-\d{2}-\d{2}T/  # ISO timestamp
    end

    test "handles empty message" do
      {:ok, result} = Echo.execute("", %{})

      assert result =~ "Echo: "
    end
  end
end

Create test/my_app/skills/log_fetcher_test.exs:

defmodule MyApp.Skills.LogFetcherTest do
  use ExUnit.Case

  alias MyApp.Skills.LogFetcher

  describe "__skill_info__/0" do
    test "declares execute and read tools" do
      info = LogFetcher.__skill_info__()

      assert :execute in info.allowed_tools
      assert :read in info.allowed_tools
    end
  end

  describe "read/3" do
    test "returns help text" do
      {:ok, help} = LogFetcher.read("help", %{}, [])

      assert help =~ "Log Fetcher Commands"
      assert help =~ "fetch"
      assert help =~ "stats"
    end

    test "returns error for unknown path" do
      {:error, message} = LogFetcher.read("unknown", %{}, [])

      assert message =~ "Unknown path"
    end
  end
end

Run tests:

mix test test/my_app/skills/

Step 6: Integrate with Ecto

Native skills can access your database directly:

defmodule MyApp.Skills.Database do
  @behaviour Conjure.NativeSkill

  @impl true
  def __skill_info__ do
    %{
      name: "database",
      description: "Query the application database for user and order information.",
      allowed_tools: [:execute, :read]
    }
  end

  @impl true
  def execute(query, _context) do
    case parse_query(query) do
      {:users, :count} ->
        count = MyApp.Repo.aggregate(MyApp.User, :count)
        {:ok, "Total users: #{count}"}

      {:users, :recent, limit} ->
        users = MyApp.Repo.all(
          from u in MyApp.User,
          order_by: [desc: u.inserted_at],
          limit: ^limit,
          select: %{id: u.id, email: u.email, created: u.inserted_at}
        )
        {:ok, Jason.encode!(users, pretty: true)}

      {:orders, :stats} ->
        stats = get_order_stats()
        {:ok, Jason.encode!(stats, pretty: true)}

      _ ->
        {:error, "Unknown query. Try 'count users' or 'recent users 10'."}
    end
  end

  @impl true
  def read(table, _context, opts) do
    case table do
      "users" -> {:ok, describe_table(MyApp.User)}
      "orders" -> {:ok, describe_table(MyApp.Order)}
      _ -> {:error, "Unknown table: #{table}"}
    end
  end

  defp describe_table(schema) do
    fields = schema.__schema__(:fields)
    types = Enum.map(fields, &{&1, schema.__schema__(:type, &1)})

    """
    Table: #{schema.__schema__(:source)}
    Fields:
    #{Enum.map_join(types, "\n", fn {f, t} -> "  - #{f}: #{t}" end)}
    """
  end
end

Multiple Native Skills

Combine multiple native skills in one session:

session = Conjure.Session.new_native([
  MyApp.Skills.Echo,
  MyApp.Skills.LogFetcher,
  MyApp.Skills.Database
])

# Claude can use any of these skills
{:ok, response, _} = Conjure.Session.chat(
  session,
  "First check how many users we have, then fetch recent error logs",
  &api_callback/1
)

Tool Generation

Conjure automatically generates Claude tool definitions:

tools = Conjure.Backend.Native.tool_definitions([MyApp.Skills.LogFetcher])

# Generates:
# [
#   %{
#     "name" => "log_fetcher_execute",
#     "description" => "Execute a command...",
#     "input_schema" => %{...}
#   },
#   %{
#     "name" => "log_fetcher_read",
#     "description" => "Read a resource...",
#     "input_schema" => %{...}
#   }
# ]

Best Practices

  1. Keep skills focused - One responsibility per skill
  2. Return structured data - Use JSON for complex outputs
  3. Handle errors gracefully - Return {:error, reason} with helpful messages
  4. Test thoroughly - Native skills are easy to test with ExUnit
  5. Document commands - Provide help text via the read callback

Next Steps