Skip to main content

castia

Idiomatic, FastAPI-style Python SDK for Microsoft Foundry hosted agents.

castia lets a hosted agent speak Foundry's three wire protocols — Activity (Teams/Bot Framework), OpenAI responses, and invocations — through protocol-named decorators, dependency injection (Depends), and typed builders for messages, Adaptive Cards, entities, and invoke envelopes. You decorate a handler, return a value, and the framework does the rest — the auth chains, hosting, Activity routing, and telemetry stay out of your file.

Install

pip install castia
# or, with uv:
uv add castia

Requires Python 3.11+.

Quickstart

from castia import Agent, Depends, Model, Teams

app = Agent(name="my-agent")

def gpt4o() -> Model:
    return Model("gpt-4o")

@app.activity(Teams.direct, Teams.group, Teams.channel_mention)
async def reply(text: str, model: Model = Depends(gpt4o)) -> str:
    return await model.respond(text)

if __name__ == "__main__":
    app.run()

Decorate a handler with the surfaces it answers on, return a str, and the framework sends it as the Teams reply. The model is built once for the process and injected via Depends — the model choice stays visible in your file instead of being buried in the framework. (Model() with no argument falls back to AZURE_AI_MODEL_DEPLOYMENT_NAME; get_model / use_model("gpt-4o") are zero-config conveniences.)

Composing protocols with routers

Like FastAPI's include_router, an Agent composes Routers so each protocol can live in its own module:

from castia import Agent
from handlers import activity, responses, invocations

app = Agent(name="my-agent")
app.include(activity.router, responses.router, invocations.router)

Richer replies

Handlers can take a Message and reach for typed builders — Adaptive Cards, suggested actions, citations, mentions, sensitivity labels, live-typing streamers, and reactions:

from castia import Depends, Message, Model, Reaction, Router, Teams, get_model

router = Router()

@router.activity(Teams.direct)
async def reply(text: str, msg: Message, model: Model = Depends(get_model)) -> None:
    await msg.react(Reaction.eyes)
    answer = await model.respond(text)
    await msg.say(answer)

Consume a toolbox

A Foundry toolbox is a curated set of tools the platform exposes behind one MCP-compatible endpoint, with centralized auth, governance, and versioning. castia turns it into a single Responses-API mcp tool spec the model service resolves server-side — no local impl, no function loop. Build the spec and hand it to the model as an extra_specs entry:

from castia import Depends, Model, Teams, get_model, toolbox_mcp_tool, toolbox_token

@app.activity(Teams.direct)
async def reply(text: str, model: Model = Depends(get_model)) -> str:
    tool = toolbox_mcp_tool(token=await toolbox_token())   # reads TOOLBOX_* env
    return await model.respond_with_tools(
        text, tools=[], activity=None, extra_specs=[tool] if tool else []
    )

toolbox_mcp_tool() (called with no endpoint) reads the environment via resolve_toolbox_endpoint, whose precedence is:

  1. an explicit full URL in TOOLBOX_ENDPOINT / TOOLBOX_MCP_ENDPOINT;
  2. the platform-native TOOLBOX_<NAME>_MCP_ENDPOINT that the azd ai toolbox extension writes, keyed off TOOLBOX_NAME (see platform_endpoint_env);
  3. composed from FOUNDRY_PROJECT_ENDPOINT + TOOLBOX_NAME (+ optional TOOLBOX_VERSION). The unversioned URL resolves the promoted default version, so a version bump needs no redeploy.

It returns None when no toolbox is configured, so "no toolbox" just attaches no tool. Auth is either a bearer token (minted from the container's managed identity by toolbox_token()) or a stored-connection project_connection_id. A Foundry IQ knowledge base is the same shape via knowledge_base_mcp_tool.

Deploying: the azd ai toolbox extension writes TOOLBOX_<NAME>_MCP_ENDPOINT into the azd environment, but does not auto-inject it into a hosted container — declare that env passthrough on your container yourself (there is no azure.yaml/manifest step in castia for it).

Gotcha: do not copy rai_config.rai_policy_name: Microsoft.Default from the azd ai toolbox create --help example — it is invalid on the project and 500s at tool enumeration (tools/list). Omit the policies block.

Validation status. Validated live (Foundry Responses path, App Insights-traced): the end-to-end pipe (env → compose URL → attach one mcp tool → tools/list + tools/call), a raw https://ai.azure.com bearer minted in-container (no project_connection_id required), both env forms, and unversioned→default-version resolution. Doc-derived / not yet live: knowledge_base_mcp_tool (Foundry IQ), connection-backed tools (Azure AI Search / remote-MCP / A2A), the Activity path with a toolbox, and approval-gated tools (require_approval other than "never").

Protocols

castia publishes handlers for the protocols in PUBLISHABLE_PROTOCOLS:

  • Activity — Teams / Bot Framework message and invoke turns.
  • responses — the OpenAI responses wire shape.
  • invocations — Foundry invoke envelopes (tool execution, agent-to-agent).

Observability & evaluation

castia configures Foundry/Agent 365 telemetry for you when the agent starts. By default it emits GenAI spans (the chat {model} spans the Foundry Traces UI keys off) but does not record the prompt/response content onto them.

Recording content is what makes an agent's traces evaluable — trace-based evaluators read the input/output text from the GenAI spans, which is only present when content recording is enabled. Turn it on deliberately via configure_observability:

from castia.observability import configure_observability

# Records prompt/response text onto GenAI spans so traces can be evaluated.
configure_observability(enable_content_recording=True)

Resolution order for each flag is explicit argument > environment variable > default:

Flag Argument Environment variable Default
Content recording enable_content_recording AZURE_TRACING_GEN_AI_CONTENT_RECORDING_ENABLED off
GenAI tracing enable_genai_tracing AZURE_EXPERIMENTAL_ENABLE_GENAI_TRACING on

Passing nothing preserves the default behavior. Telemetry setup is best-effort: a failure is logged, never raised, so it can't break startup or a turn.

Security caveat: enabling content recording writes prompt and response text to Application Insights. Only enable it where storing that content is acceptable for your data-handling and privacy requirements.

Building a scored eval suite

Once your traces are evaluable, python -m castia eval wraps the azd ai agent eval extension to synthesize and run a scored eval suite — a generated JSONL dataset plus an auto-generated, weighted rubric (a custom multi-dimension evaluator):

# Offline gate — validate eval.yaml + rubric files, no Azure, free in CI:
python -m castia eval check

# Synthesize a rubric + dataset from the agent instruction (billable):
python -m castia eval generate --agent my-agent --max-samples 25

# Re-upload locally edited rubric/dataset files as a new version:
python -m castia eval update --evaluator-only

# Submit a scored run against the deployed agent (billable):
python -m castia eval run

check is a pure, offline referential-integrity gate: it resolves every evaluator/dataset local_uri and validates each rubric dimensions file. generate and run submit billable Foundry jobs, so both accept --dry-run to print the resolved azd command line without submitting anything. The azd wrappers need the build-time extra: pip install 'castia[deploy]'.

The rubric dimensions file is a bare JSON list where each entry is keyed by id (a stable slug like correct_outcome), with an optional always_applicable: true on the catch-all dimension. That cross-SDK shape is pinned in the monorepo at spec/conformance/rubric/.

Optimizer-readiness

The Foundry Agent Optimizer searches for a better system prompt (and, when you declare tools, better tool descriptions) by running candidates against your eval suite. Making a castia agent optimizer-ready is three things: install the runtime resolver, ship a baseline config, and source your model + instructions from that config instead of hardcoding them — so the optimizer can swap in a candidate with zero handler changes.

Bind your model dependency with configured_model() and thread its resolved instructions through:

from castia import Depends, Model, Router, configured_model

router = Router()
gpt = configured_model()  # resolves baseline (or the injected candidate) once

@router.responses()
async def reply(text: str, model: Model = Depends(gpt)) -> str:
    return await model.respond(text)   # instructions flow into responses.create

configured_model() calls load_agent_config(), which is best-effort: if the optimizer package isn't installed, resolution fails, or no config is found, it degrades to environment defaults (AZURE_AI_MODEL_DEPLOYMENT_NAME, no instructions) — the agent runs identically with or without the optimizer.

Ship a baseline under .agent_configs/baseline/:

.agent_configs/baseline/
  metadata.yaml       # model, instruction_file, (optional) tool_file pointers
  instructions.md     # the system prompt the optimizer tunes
  tools.json          # optional: tool specs the optimizer may reword

If your agent declares tools with app.tools(...), keep the baseline tools.json in sync with the code using the build-time reconciler:

python -m castia optimize          # write/refresh .agent_configs/baseline/tools.json
python -m castia optimize --check   # CI drift gate (exits non-zero, writes nothing)

Config resolution order is first-wins: OPTIMIZATION_CONFIG (inline JSON) → resolver API (OPTIMIZATION_CANDIDATE_ID + OPTIMIZATION_RESOLVE_ENDPOINT) → local .agent_configs/ → environment defaults. An explicit config_dir argument (or OPTIMIZATION_LOCAL_DIR) affects only the local source — pass it anchored to your app root so the baseline resolves the same under python app.py and python -m castia. That contract is pinned for every SDK in spec/conformance/optimization/.

Switching to a reasoning (or RFT-tuned) model

The optimizer's model search can land on a reasoning model — an o-series or GPT-5 deployment, or one you mint yourself with reinforcement fine-tuning (RFT). Those models take a reasoning.effort control that plain chat models don't. Model exposes it as reasoning_effort (minimal|low|medium|high):

o4 = use_model("o4-mini-rft-2025", reasoning_effort="high")

An unset effort omits the field entirely, so chat models are called exactly as before; a bad level raises at construction rather than as a 400 mid-turn. An operator can also switch a deployed agent onto a reasoning model with zero code by setting MODEL_REASONING_EFFORT — an explicit argument still wins. Because configured_model() builds its Model through the same path, that env override flows through to the resolved candidate automatically.

Responses-only constraint: the optimizer accepts only single-protocol responses agents — submitting a multi-protocol agent (one that also speaks activity/invocations) is rejected with a 400 at submission. Project a responses-only sibling from the same handler code with app.responses_only(), deploy that as its own service, optimize it, then apply the winning .agent_configs candidate back to your live agent.

The runtime resolver and the reconciler need the optimizer extra: pip install 'castia[optimize]'.

Reinforcement fine-tuning (RFT)

RFT is the fourth lifecycle step — build → evaluate → optimize → switch models. It trains a reasoning model against a grader (a reward function) instead of labeled answers, minting a new fine-tuned deployment that becomes a candidate in the optimizer's model search. castia ships the build-time tooling to prepare, validate, and (behind one guarded seam) submit an RFT job — the same three-seam shape as the eval suite: pure builders, offline validators, and one billable submit seam.

# Offline gate — validate an RFT dataset (+ grader), no Azure, free in CI:
python -m castia finetune check --dataset train.jsonl --validation val.jsonl --grader grader.json

# Bridge an eval rubric into a score_model grader (offline):
python -m castia finetune grader --rubric rubric.json --model gpt-4o --out grader.json

# Submit a billable RFT job (use --dry-run to print the payload and submit nothing):
python -m castia finetune submit --model o4-mini --dataset train.jsonl \
  --validation val.jsonl --grader grader.json --reasoning-effort high --dry-run

A grader is one of string_check, text_similarity, score_model, python, multi, or endpoint (preview); templates reference two namespaces only — {{ sample.output_text }} and {{ item.<field> }}. Datasets are JSONL chat messages[] rows whose final message role must be user, with extra top-level keys as the item.* ground truth; both train and validation splits are required. rubric_to_score_model() bridges an eval rubric straight into a score_model grader — the natural tie between the evaluate and switch-models steps.

⚠️ Provisional / doc-derived. The grader JSON schema, the RFT hyperparameter names, and that fine_tuning.jobs.create accepts this payload are derived from the Foundry RFT how-to and have not been confirmed against a live RFT job. The builders and validators are fully offline-tested; treat the submitted wire shape as provisional until a real submission validates it. The language-neutral contract is pinned in spec/conformance/graders/.

Design

castia is deliberately import-cheap: import castia never pulls in the instrumented Azure/OpenAI/httpx stacks, so telemetry can be configured before those libraries load. The heavy imports are deferred into the methods that need them.

License

MIT © 2026 Seth Juarez

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

castia-0.4.0.tar.gz (109.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

castia-0.4.0-py3-none-any.whl (102.6 kB view details)

Uploaded Python 3

File details

Details for the file castia-0.4.0.tar.gz.

File metadata

  • Download URL: castia-0.4.0.tar.gz
  • Upload date:
  • Size: 109.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for castia-0.4.0.tar.gz
Algorithm Hash digest
SHA256 efeb7a6414cae010e9c3126fa411aa4020aa40b51a9f8b812d2211936a32aded
MD5 28b574587072cba6425ebe1f87ce89ee
BLAKE2b-256 d30dabe083f3b282e432b0b90b9c6363122f9586b6d74b83553edc076e6287ef

See more details on using hashes here.

Provenance

The following attestation bundles were made for castia-0.4.0.tar.gz:

Publisher: release-please.yml on sethjuarez/castia

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file castia-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: castia-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 102.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for castia-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 284f920ec14963d8682d04d861926383f1fb518b963ed8b6902ed9f5b087654e
MD5 2a490c485c4b9827e3cffe46c911cf80
BLAKE2b-256 44f4463c29dcb6610f35ce800c526f87e84e806ad03d6f45871f995a5e3d3aa0

See more details on using hashes here.

Provenance

The following attestation bundles were made for castia-0.4.0-py3-none-any.whl:

Publisher: release-please.yml on sethjuarez/castia

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.5.0

2 files

This release

0.4.0 This release

2 files

0.3.0

2 files

0.2.0

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page