Writing Custom Skills¶
Custom skills are Elixir modules implementing the AlexClaw.Skill behaviour. They can be loaded at runtime without rebuilding the container.
Skill Behaviour¶
defmodule AlexClaw.Skills.Dynamic.MySkill do
@moduledoc "A custom skill that does something useful."
@behaviour AlexClaw.Skill
@impl true
def run(args) do
input = args.input
config = args.config
# Your logic here
result = process(input, config)
{:ok, result, :on_success}
end
@impl true
def description, do: "Does something useful with the input"
@impl true
def permissions, do: [:web_read, :llm]
@impl true
def routes, do: [:on_success, :on_error]
# UI metadata — controls which fields appear in the step editor
@impl true
def step_fields, do: [:llm_tier, :llm_model, :prompt_template, :config]
@impl true
def config_hint, do: ~s|{"option": "value"}|
end
Required Callbacks¶
| Callback | Return | Description |
|---|---|---|
run/1 | {:ok, result} or {:ok, result, :branch} or {:error, reason} | Main execution |
Optional Callbacks¶
Core¶
| Callback | Default | Description |
|---|---|---|
description/0 | "<name> skill" | Human-readable description |
permissions/0 | [] | Required permissions (see Authorization) |
routes/0 | [] | Possible outcome branches for conditional routing |
version/0 | "1.0.0" | Skill version string |
external/0 | false | Whether the skill fetches data from external sources |
UI Metadata¶
These callbacks control how the workflow step editor renders when your skill is selected. If not declared, all fields are shown (backward compatible).
| Callback | Default | Description |
|---|---|---|
step_fields/0 | [:llm_tier, :llm_model, :prompt_template, :config] | Which optional fields to show in the step editor. Use [:config] for skills that don't use LLM. Use [] for skills with no configurable fields. |
config_hint/0 | "" | Placeholder text shown in the config JSON textarea |
config_scaffold/0 | %{} | Default config map pre-filled when adding a new step |
config_presets/0 | %{} | Named config templates shown as buttons (e.g. %{"GET" => %{"method" => "GET"}}) |
prompt_presets/0 | %{} | Named prompt templates shown as buttons (e.g. %{"Summarize" => "Summarize:\n\n{input}"}) |
config_help/0 | "Skill-specific parameters as JSON." | Help text shown as tooltip on the config field |
prompt_help/0 | "Template sent to the LLM. Use {input} for previous step output." | Help text shown as tooltip on the prompt field |
The args Map¶
Every skill receives a map with these keys:
| Key | Type | Description |
|---|---|---|
input | any | Output from the previous workflow step (or user input) |
config | map | Step-specific configuration from the workflow editor |
resources | list | Resources assigned to the workflow |
workflow_run_id | integer or nil | Current run ID (nil if standalone) |
llm_provider | string or nil | Provider override |
llm_tier | string | LLM tier for this step |
prompt_template | string or nil | Handlebars template for LLM input |
Using SkillAPI¶
Dynamic skills interact with the system through AlexClaw.Skills.SkillAPI:
# Search the web (requires :web_read permission)
{:ok, results} = SkillAPI.web_search(__MODULE__, query)
# Call an LLM (requires :llm permission)
{:ok, response} = SkillAPI.llm_call(__MODULE__, prompt, tier: :medium)
# Store in memory (requires :memory_write permission)
{:ok, entry} = SkillAPI.store_memory(__MODULE__, :fact, content, source: url)
# Search memory (requires :memory_read permission)
results = SkillAPI.search_memory(__MODULE__, query, limit: 10)
External Skills¶
If your skill fetches data from external sources (HTTP requests, APIs, RSS feeds), declare external/0:
This enables automatic content sanitization when your skill's output flows through the workflow engine. The ContentSanitizer strips prompt injection payloads from external content before it reaches the LLM.
AST enforcement: At load time, the registry scans your source for calls to HTTP/socket libraries (Req, HTTPoison, Finch, Tesla, :gen_tcp, SkillAPI.http_*). If detected without external/0, your skill is rejected. This is fail-closed — no exceptions.
# This will be REJECTED — uses Req.get but doesn't declare external/0
defmodule AlexClaw.Skills.Dynamic.BadFetcher do
@behaviour AlexClaw.Skill
def permissions, do: [:web_read]
def run(args) do
{:ok, resp} = Req.get(args[:input])
{:ok, resp.body, :on_success}
end
end
# This will be ACCEPTED — declares external/0
defmodule AlexClaw.Skills.Dynamic.GoodFetcher do
@behaviour AlexClaw.Skill
@impl true
def external, do: true
def permissions, do: [:web_read]
def run(args) do
{:ok, resp} = Req.get(args[:input])
{:ok, resp.body, :on_success}
end
end
Namespace Requirement¶
Dynamic skills must be in the AlexClaw.Skills.Dynamic.* namespace:
# Correct
defmodule AlexClaw.Skills.Dynamic.MySkill do
# Wrong — will be rejected
defmodule MySkill do
Returning Results¶
# Success with branch (for conditional routing)
{:ok, "processed data", :on_success}
# Success without branch (linear workflows)
{:ok, "processed data"}
# Error
{:error, "something went wrong"}
Example: URL Health Checker¶
defmodule AlexClaw.Skills.Dynamic.UrlHealthCheck do
@moduledoc "Check if a list of URLs are responding."
@behaviour AlexClaw.Skill
@impl true
def external, do: true
@impl true
def run(args) do
urls = parse_urls(args.input)
results =
Enum.map(urls, fn url ->
case Req.get(url, receive_timeout: 5_000) do
{:ok, %{status: status}} -> %{url: url, status: status, ok: status < 400}
{:error, reason} -> %{url: url, status: nil, ok: false, error: inspect(reason)}
end
end)
down = Enum.reject(results, & &1.ok)
if Enum.empty?(down) do
{:ok, "All #{length(results)} URLs are healthy", :on_success}
else
{:ok, "#{length(down)}/#{length(results)} URLs are down:\n" <>
Enum.map_join(down, "\n", &" - #{&1.url}"), :on_error}
end
end
@impl true
def description, do: "Check if URLs are responding (HTTP status < 400)"
@impl true
def permissions, do: [:web_read]
@impl true
def routes, do: [:on_success, :on_error]
# UI metadata — step editor shows only config, no LLM fields
@impl true
def step_fields, do: [:config]
@impl true
def config_hint, do: ~s|{"timeout": 5000}|
@impl true
def config_scaffold, do: %{"timeout" => 5000}
@impl true
def config_help, do: "timeout: HTTP timeout in ms per URL (default 5000). Input: newline-separated URLs."
defp parse_urls(input) when is_binary(input), do: String.split(input, "\n", trim: true)
defp parse_urls(input) when is_list(input), do: input
defp parse_urls(_), do: []
end
Example: Scraper (No LLM)¶
A skill that scrapes data and embeds it — no LLM fields needed in the step editor:
defmodule AlexClaw.Skills.Dynamic.HexdocsScraper do
@behaviour AlexClaw.Skill
@impl true
def step_fields, do: [:config]
@impl true
def config_hint, do: ~s|{"packages": ["phoenix", "ecto"]}|
@impl true
def config_scaffold, do: %{"packages" => []}
@impl true
def config_help, do: "packages: list of Hex package names to scrape and index."
@impl true
def run(args) do
# ... scraping logic
end
end
The step editor will show only the Config (JSON) field — no LLM Tier, Provider, or Prompt Template.