> ## Documentation Index
> Fetch the complete documentation index at: https://docs.xpander.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Connect Custom Skills

> Turn a Python function into a skill the agent can call with @register_tool

Custom skills are plain Python functions you decorate with `@register_tool` so the agent can call them like any other skill. In the SDK a skill is a `Tool`, and a custom skill is a custom function; `agent.tools` lists both kinds.

Use this when:

* The skills catalog doesn't have what you need.
* You want to call a private API.
* You want the LLM to run logic that already lives in your codebase.

## Prerequisites

* **Complete the [Quickstart](/developers/quickstart)** so the CLI, SDK, and `xpander login` are already set up.
* **Python 3.12+** for the local handler.
* **At least one agent loaded in code** via `Agents().aget(agent_id=...)` or inside an `@on_task` handler.

## 1. Turn a Python function into a skill

Write a function with type hints and a docstring, decorate it with `@register_tool`, and make sure the module is imported by your handler. That's all the agent needs to start calling it.

```python xpander_handler.py highlight={3,5} theme={"dark"}
from xpander_sdk import register_tool

@register_tool
def weather_check(location: str) -> str:
    """Check the current weather for a city."""
    return f"Weather in {location}: Sunny, 25C"
```

What this means in practice:

1. **You don't write the schema yourself.** Type hints are the schema. `location: str` becomes a required string parameter; add `= "Paris"` and it becomes optional with a default.
2. **The docstring is the skill's pitch to the LLM.** It's the text the model reads when deciding whether to use this skill, so write it for that audience: what the function does, what kind of input it expects, what it returns.
3. **The function name becomes the skill name.** The LLM calls it as `weather_check`. Pick names the model can disambiguate from other skills on the agent.
4. **The decorator runs at import time.** As long as the module containing it is imported by your handler, the skill shows up in `agent.tools`. Conditional imports break discovery (the skill will be silently missing) so import the module unconditionally and gate the function's behavior inside its body.
5. **The function stays normal Python.** You can unit-test it, call it from scripts, and refactor it without affecting the agent's other capabilities.

## 2. Use Pydantic for richer input schemas

When the LLM benefits from structured input (nested objects, enums, validated fields), use a Pydantic model as the parameter type. The decorator picks up the model and exposes the same field constraints to the LLM that you'd get from a normal Pydantic validator.

```python tools.py highlight={5-8,11} theme={"dark"}
from typing import Optional
from pydantic import BaseModel
from xpander_sdk import register_tool

class CustomerFilter(BaseModel):
    status: str
    region: Optional[str] = None
    min_lifetime_value: float = 0.0

@register_tool
def search_customers(filter: CustomerFilter, limit: int = 50) -> list[dict]:
    """Search customers by status, optionally filtered by region and minimum lifetime value."""
    # Your implementation goes here.
    return []
```

What this means in practice:

1. **The model's fields become a nested object in the skill's schema.** The LLM sees `status` (required), `region` and `min_lifetime_value` (optional with defaults), and supplies them as a structured payload.
2. **The SDK validates the incoming payload before your function runs.** A bad value never reaches your code; the agent gets a `ValueError` it can react to in the next turn.
3. **Anything Pydantic can model, you can use here.** `Optional`, `Union`, `Literal`, `Enum`, field validators. Whatever the model expresses ends up in the schema the LLM sees.

## 3. Make the skill async when it does I/O

Most skills that matter make a network call, hit a database, or talk to a queue. Make the function `async def` and the agent loop awaits it for you. Sync and async skills coexist on the same agent; you pick per function.

```python highlight={5,7-8} theme={"dark"}
import httpx
from xpander_sdk import register_tool

@register_tool
async def fetch_external_data(api_endpoint: str, headers: dict = None) -> dict:
    """Fetch JSON from an external HTTP endpoint."""
    async with httpx.AsyncClient() as client:
        response = await client.get(api_endpoint, headers=headers or {})
        return response.json()
```

What this means in practice:

1. **`async def` is the only difference from a sync skill.** Type hints, docstring, decorator, all the same.
2. **Use async-native clients for the actual I/O.** `httpx.AsyncClient` for HTTP, `asyncpg` or SQLAlchemy 2.x async for Postgres, `aio-pika` for RabbitMQ, etc. A blocking client inside an `async def` blocks the event loop and slows every other skill call running concurrently.
3. **Return the value, don't return the coroutine.** Always `await` the I/O inside the function. If you forget the `await`, the SDK passes the coroutine object back as the skill result and the LLM sees garbage.

## 4. Wire custom skills into your agent loop

Once decorated, the skill joins `agent.tools.list` alongside catalog skills and any skills served over MCP. You don't write per-loop registration glue: each agent loop (Agno, OpenAI Agents SDK, LangChain, AWS Strands) reads the same agent property it already uses for catalog skills, and your custom skill comes along for the ride.

<Tabs>
  <Tab title="Agno">
    Custom skills are already inside the args dict returned by `Backend.aget_args()`. Just splat the args into Agno's `Agent`:

    ```python xpander_handler.py highlight={7,15-16} theme={"dark"}
    from dotenv import load_dotenv
    load_dotenv()

    from xpander_sdk import on_task, Task, Backend, register_tool
    from agno.agent import Agent

    @register_tool
    def weather_check(location: str) -> str:
        """Check the current weather for a city."""
        return f"Weather in {location}: Sunny, 25C"

    @on_task
    async def handler(task: Task) -> Task:
        backend = Backend(configuration=task.configuration)
        # weather_check is already in args["tools"] alongside the connectors.
        agno_agent = Agent(**(await backend.aget_args(task=task)))
        result = await agno_agent.arun(input=task.to_message())
        task.result = result.content
        return task
    ```
  </Tab>

  <Tab title="OpenAI Agents SDK">
    Custom skills appear in `agent.openai_agents_sdk_tools` as `FunctionTool` objects, ready for the OpenAI runner:

    ```python highlight={4,13} theme={"dark"}
    from xpander_sdk import Agents, register_tool
    from agents import Agent as OpenAIAgent, Runner

    @register_tool
    def weather_check(location: str) -> str:
        """Check the current weather for a city."""
        return f"Weather in {location}: Sunny, 25C"

    xpander_agent = await Agents().aget(agent_id="agt_01H...")
    oa_agent = OpenAIAgent(
        name=xpander_agent.name,
        instructions=xpander_agent.instructions.full,
        tools=xpander_agent.openai_agents_sdk_tools,   # connectors + weather_check
        model=xpander_agent.model_name,
    )
    result = await Runner.run(oa_agent, input="Weather in Paris?")
    ```
  </Tab>

  <Tab title="LangChain">
    Custom skills appear in `agent.tools.functions` as plain Python callables. Pass them straight to `create_react_agent`:

    ```python highlight={5,13} theme={"dark"}
    from xpander_sdk import Agents, register_tool
    from langchain_openai import ChatOpenAI
    from langgraph.prebuilt import create_react_agent

    @register_tool
    def weather_check(location: str) -> str:
        """Check the current weather for a city."""
        return f"Weather in {location}: Sunny, 25C"

    xpander_agent = await Agents().aget(agent_id="agt_01H...")
    react = create_react_agent(
        model=ChatOpenAI(model=xpander_agent.model_name),
        tools=xpander_agent.tools.functions,           # connectors + weather_check
        state_modifier=xpander_agent.instructions.full,
    )
    ```
  </Tab>

  <Tab title="AWS Strands">
    Custom skills appear in `agent.strands_tools`. Pass the list to Strands' `Agent`:

    ```python highlight={4,13} theme={"dark"}
    from xpander_sdk import Agents, register_tool
    from strands.agent import Agent as StrandsAgent

    @register_tool
    def weather_check(location: str) -> str:
        """Check the current weather for a city."""
        return f"Weather in {location}: Sunny, 25C"

    xpander_agent = await Agents().aget(agent_id="agt_01H...")
    strands_agent = StrandsAgent(
        name=xpander_agent.name,
        system_prompt=xpander_agent.instructions.full,
        tools=xpander_agent.strands_tools,             # connectors + weather_check
        model=xpander_agent.model_name,
    )
    ```
  </Tab>
</Tabs>

The decorator emits one `Tool` definition; each per-loop property wraps it in the shape that agent loop expects.

## 5. Make a skill selectively available

`@register_tool` adds a skill to the agent's permanent capability set: every task gets it. If you only want a function available for a single call (a debug helper, a test stub, a per-request override), skip the decorator and pass it through `Backend.aget_args(tools=[...])` instead. The function gets appended to the resolved `tools` list for that one call only. The agent's permanent capability set stays untouched:

```python highlight={6-8,12} theme={"dark"}
from datetime import datetime
from xpander_sdk import Backend

backend = Backend(configuration=task.configuration)

def _local_clock() -> str:
    """Return the developer's local clock for debugging."""
    return datetime.now().isoformat()

args = await backend.aget_args(
    task=task,
    tools=[_local_clock],   # appended to connectors + @register_tool functions
)
```

Reach for `@register_tool` when a skill belongs to the agent for good. Reach for `tools=[...]` when it's situational.

## 6. Inspect and invoke custom skills directly

Custom skills are queryable through the same APIs as catalog skills. Reach for these when you want to sanity-check that your decorator picked them up, build a UI showing the agent's full surface, or invoke a skill by hand outside the LLM loop.

```python theme={"dark"}
from xpander_sdk import Agents

agent = await Agents().aget(agent_id="agt_01H...")

# Every tool on the agent: connectors + @register_tool functions + MCP tools.
for tool in agent.tools.list:
    kind = "local" if tool.is_local else "connector"
    print(tool.name, kind, tool.description[:60])

# Look up a single custom tool and invoke it directly.
weather_tool = agent.tools.get_tool_by_name("weather_check")
result = await agent.ainvoke_tool(
    tool=weather_tool,
    payload={"location": "Paris"},
)
print(result.is_success, result.result)
```

What this means in practice:

1. **Custom skills and catalog skills look the same to the runtime.** They share `agent.tools.list`, the same lookup helpers, and the same `ainvoke_tool` entry point. `tool.is_local` distinguishes the two when you need to.
2. **Direct invocation skips the LLM.** Useful for migration scripts, batch jobs, or testing the skill's contract before exposing it to the model.
3. **The payload structure follows the schema you declared.** For a function typed with primitives, the payload is the keyword args dict. For a Pydantic-typed parameter, the payload is the model's serialized form.

| Property | Returns | What it's for |
| - | - | - |
| `agent.tools.list` | `list[Tool]` | Canonical enumeration. Each `Tool` carries `id`, `name`, `description`, `parameters` (JSON schema), `is_local`, and a Pydantic `schema`. |
| `agent.tools.functions` | `list[Callable]` | Normalized callables for every skill with a `payload: <PydanticModel>` parameter. Bind into LangChain or any agent loop that takes plain Python functions. |
| `agent.tools.get_tool_by_name(name)` / `get_tool_by_id(id)` | `Tool` | Look up a single skill. `id` and `name` are equal for `@register_tool` functions (both default to the function's `__name__`). |

## 7. Register custom skills with the runtime

By default, a registered function only exists inside your handler process. The agent loop can call it because it's in the local registry, but the runtime's record of the agent doesn't list it. It won't appear in the agent's **Skills** row in Xpander Chat, and you can't reference it in dependency rules. To register the skill with the runtime too, pass `add_to_graph=True`:

```python highlight={1} theme={"dark"}
@register_tool(add_to_graph=True)
async def lookup_internal_ticket(ticket_id: str) -> dict:
    """Look up an internal support ticket by ID."""
    ...
```

What this means in practice:

1. **Code changes need a new rollout of your handler.** Adding, editing, or removing a `@register_tool` function changes the code. Settings changes (instructions, model, attached skills) stay live without one.
2. **`add_to_graph=True` syncs on agent load, not on deploy.** The first task after rollout triggers the runtime sync; the deploy itself doesn't. The flag is idempotent. Subsequent loads skip already-synced skills, so you can leave it set permanently.
3. **Leave `add_to_graph` off when you don't need runtime-side features.** The LLM doesn't need it to call the skill, and skipping it keeps Xpander Chat's view scoped to runtime-managed configuration.

## Troubleshooting

<AccordionGroup>
  <Accordion title="Custom skill doesn't show up in `agent.tools.list`">
    The decorator runs at import time. If the module that holds the function is never imported (because the handler has a conditional import, or because the function lives in a file the handler doesn't reference), the decorator never executes and the skill is invisible. Move the `@register_tool` function into a module the handler imports unconditionally, or add an explicit `from .tools import *` to your handler's top imports.
  </Accordion>

  <Accordion title="Schema doesn't match what the LLM sends">
    Schema generation reads type hints, not runtime values. A parameter typed as `dict` produces a generic object schema with no field constraints; the LLM will fill it with whatever it thinks is reasonable. To get a strict schema, model the parameter with a Pydantic `BaseModel` (Section 2). For unions and enums, use `typing.Union` and `enum.Enum` so Pydantic emits the right schema fragments.
  </Accordion>

  <Accordion title="`TypeError` because a parameter is missing a type hint">
    Untyped parameters fall back to `Any`, which produces a permissive schema and often leads to runtime errors when the LLM passes the wrong type. Annotate every parameter. If you really need a free-form value, use `typing.Any` explicitly so future readers know it was a deliberate choice.
  </Accordion>

  <Accordion title="Async skill runs but the agent never sees the result">
    The agent loop awaits coroutines automatically, but only if the function itself is `async def`. If you write `def run(...): return some_coroutine()` instead of `async def run(...)`, the SDK passes the coroutine object back as the skill result and the LLM gets garbage. Make the function `async def` and `await` inside it.
  </Accordion>

  <Accordion title="`add_to_graph=True` skill isn't appearing in Xpander Chat">
    The graph sync runs in the background the next time the agent is loaded with `Agents().aget(agent_id=...)`. If you only ever call `Backend.aget_args(task=task)` inside an `@on_task` handler, the agent is loaded each task and the sync fires there too. Force a sync from a script: load the agent, wait a couple of seconds for the background task, then refresh the **Skills** row in Xpander Chat.
  </Accordion>

  <Accordion title="Output schema configured in Xpander Chat but the LLM still sees the full payload">
    Output schema filtering applies to remote catalog skill calls, not to `@register_tool` functions. Whatever your function returns reaches the LLM verbatim. To shrink a local skill's output, project to the fields you want before returning, or wrap the call in a [skill call hook](/developers/tools/tool-hooks) that rewrites the response.
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Pre-built skills" icon="plug" href="/developers/tools/pre-built">
    The other half of `agent.tools.list`. Slack, Gmail, GitHub, and more.
  </Card>

  <Card title="Skill call hooks" icon="bolt" href="/developers/tools/tool-hooks">
    Observe, log, and rewrite every skill call across the agent's lifetime.
  </Card>

  <Card title="Output Response Filtering" icon="filter" href="/developers/tools/output-response-filtering">
    How large skill responses get filtered before reaching the LLM.
  </Card>

  <Card title="Agent loops" icon="cubes" href="/developers/frameworks">
    How `agent.tools.functions`, `openai_agents_sdk_tools`, and `strands_tools` map onto Agno, OpenAI Agents SDK, LangChain, and AWS Strands.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.