Python: add hosting Channels sample apps (#5645)

* samples(hosting): add hosting Channels sample apps under samples/04-hosting/af-hosting

Adds five end-to-end sample apps under
``python/samples/04-hosting/af-hosting/`` that exercise the
``agent-framework-hosting`` Channels stack from the simplest single-channel
case up to a multi-channel deployment with cross-channel identity linking.

Samples (ordered by complexity)
-------------------------------

* ``foundry_hosted_agent/`` — minimal Responses + Invocations host with a
  Foundry-backed agent and ``FoundryHostedAgentHistoryProvider``.
  ``agd``-deployable; bundles a ``Dockerfile`` and
  ``scripts/vendor-packages.sh`` that copies workspace packages into
  ``_vendor/`` for self-contained builds. ``_vendor/`` is gitignored.
* ``local_responses/`` — single-channel Responses host with a
  ``run_hook`` that strips caller-supplied options and forces a
  reasoning preset. Demonstrates the hook seam over the uniform
  ``ChannelRequest`` envelope.
* ``local_responses_workflow/`` — Responses + Invocations exposing a
  three-agent workflow with per-conversation checkpoint storage.
* ``local_telegram/`` — Responses + Telegram with a ``@tool``,
  ``FileHistoryProvider``, hooks, and a ``ResponseTarget`` multicast
  variant (``call_server_multicast.py``) that pushes a single Responses
  reply to a separate Telegram chat.
* ``local_identity_link/`` — full surface: Responses + Invocations +
  Telegram + Activity Protocol (Teams) + the ``EntraIdentityLinkChannel``
  sidecar. Resolves per-channel ids onto a single Entra object id so a
  user's history follows them across surfaces.

Notes
-----

* Samples that use Telegram/Teams via Activity Protocol depend on the
  renamed ``agent-framework-hosting-activity-protocol`` package (see the
  PR-5 series).
* All samples use ``[tool.uv.sources]`` editable workspace deps, except
  ``foundry_hosted_agent/`` which uses the ``./_vendor/`` self-contained
  layout for ``azd`` Docker builds.
* Each sample includes a ``README.md`` with run instructions and an
  ``app.py`` ASGI entrypoint plus a ``call_server.py`` client harness.

Depends on the prior hosting PRs (foundry-hosted-agent refactor +
hosting-core + the per-channel packages). After those merge, this
branch can be rebased onto ``main`` cleanly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* samples(hosting): point sample deps at the feature/python-hosting GitHub branch

Switches every sample's ``[tool.uv.sources]`` from in-monorepo
editable path deps (which only resolve when running inside the
agent-framework workspace) to git refs targeting the
``feature/python-hosting`` branch on
``microsoft/agent-framework``. Samples now install standalone outside
the monorepo while the ``agent-framework-hosting*`` packages are still
pre-PyPI; once they publish, the ``[tool.uv.sources]`` block can be
dropped and the declared deps resolve from PyPI.

Cleanup
-------

* Drops ``foundry_hosted_agent/scripts/vendor-packages.sh``,
  ``_vendor/`` from ``.gitignore``, the ``hooks.prepackage`` block in
  ``azure.yaml`` and the ``COPY _vendor/`` step in the Dockerfile —
  vendoring is no longer needed because git refs make the deps
  network-resolvable from any context.
* Drops obsolete ``workspace.pyproject.toml`` reference and ``scripts/``
  / ``workspace.pyproject.toml`` entries from
  ``Dockerfile.dockerignore``.
* Updates the foundry sample's Dockerfile to ``uv sync --no-dev``
  (no ``--frozen``) so it locks fresh against the GitHub-hosted deps
  at build time.
* Drops every committed ``uv.lock`` because the resolver needs network
  access to ``feature/python-hosting`` to lock — they regenerate the
  first time a user runs ``uv sync`` after the branch lands.
* Refreshes the per-sample READMEs to mention the GitHub install path
  instead of "in-tree workspace packages".

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* samples(hosting): address PR #5645 review comments

- foundry_hosted_agent/call_server.py: replace hard-coded
  project_endpoint and service_session_id with FOUNDRY_PROJECT_ENDPOINT,
  FOUNDRY_HOSTED_AGENT_NAME, and optional FOUNDRY_HOSTED_SESSION_ID
  environment variables. Session-id is now optional so the sample
  exercises the new-conversation path by default.

- local_identity_link/app.py:
  * make_telegram_hook: apply the reasoning bump regardless of
    identity-link state (the previous early-return on linked chats
    silently dropped the high-effort preset for the very flow the
    sample exists to demonstrate).
  * make_responses_hook: add a prominent DEV-ONLY warning that the
    client-supplied entra_oid shortcut bypasses identity verification
    and must be replaced by a JWT validator in production.
  * /link command: early-return when chat_id is missing instead of
    minting an authorize URL keyed on "telegram:None" (which would
    poison the link store with a binding any future chat_id-less
    update would collapse onto).
  * Switch ENTRA_CERT_PATH / ENTRA_CERT_PASSWORD env vars to the
    longer ENTRA_CERTIFICATE_PATH / ENTRA_CERTIFICATE_PASSWORD names
    that the README already documents.
  * channels: Sequence[Channel] -> list[Channel] (the next line
    appends, which a Sequence type doesn't expose).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore(hosting-samples): apply sample formatting

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(hosting-samples): guard command input text

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-05-28 14:57:46 +02:00
committed by GitHub
Unverified
parent fe89da15b6
commit 6b822853eb
30 changed files with 2825 additions and 0 deletions
@@ -0,0 +1,51 @@
# Multi-channel hosting samples
End-to-end samples for serving an `agent-framework` agent (or workflow)
through one or more **channels** with `agent-framework-hosting`.
The general hosting plumbing lives in
[`agent-framework-hosting`](../../../packages/hosting); each channel is
its own package (`agent-framework-hosting-responses`,
`agent-framework-hosting-invocations`,
`agent-framework-hosting-telegram`, `agent-framework-hosting-activity-protocol`,
`agent-framework-hosting-entra`).
| Sample | What it shows | Packaging |
|---|---|---|
| [`local_responses/`](./local_responses) | The minimal shape: one agent + one `@tool` + `ResponsesChannel` + a single `run_hook` that strips caller-supplied options and forces a `reasoning` preset. | **Local only.** Start here to learn the run-hook seam. |
| [`local_responses_workflow/`](./local_responses_workflow) | A 4-step `Workflow` (typed `SloganBrief` intake → writer → legal → formatter) hosted behind **both** the Responses and Invocations channels via a shared `run_hook` that parses inbound text/JSON into the workflow's typed input. The host writes per-conversation checkpoints via `checkpoint_location=…`. Demonstrates workflow targets + structured input adaptation + multi-channel + resume-across-turns. Includes a `call_server.rest` file with REST examples for both endpoints. | **Local only.** |
| [`foundry_hosted_agent/`](./foundry_hosted_agent) | One Foundry agent, **Responses + Invocations only** — the minimal shape that is **runtime-compatible with the Foundry Hosted Agents platform**. | Ships with `Dockerfile` + `agent.yaml` + `agent.manifest.yaml` + `azure.yaml` so the same image runs locally **or** as a Foundry Hosted Agent (`azd up`). |
| [`local_telegram/`](./local_telegram) | Adds Telegram, a `@tool`, `FileHistoryProvider`, run hooks (per-user / per-chat session keying), extra Telegram commands, and `ResponseTarget` multicast. Runs under Hypercorn with multiple workers. | **Local only.** No Dockerfile / Foundry packaging. |
| [`local_identity_link/`](./local_identity_link) | Everything in `local_telegram/` plus Teams and the Entra identity-link sidecar (`/auth/start` + `/auth/callback`). Demonstrates linking a Telegram chat to an Entra user so multiple non-Entra channels can share one isolation key. | **Local only.** No Dockerfile / Foundry packaging. |
Each sample is fully self-contained — its own `pyproject.toml`, `uv.lock`,
server `app.py`, calling script(s), and `storage/` directory. Every
sample uses `[tool.uv.sources]` to wire its `agent-framework-hosting*`
dependencies to the
[`feature/python-hosting`](https://github.com/microsoft/agent-framework/tree/feature/python-hosting)
branch of the upstream repo via git refs, so they install cleanly outside
the monorepo while the hosting packages are still pre-PyPI. Once those
packages publish, drop the `[tool.uv.sources]` block and let the
declared deps resolve from PyPI.
## Relationship to `../foundry-hosted-agents/`
The sibling [`../foundry-hosted-agents/`](../foundry-hosted-agents) directory
contains samples for the **`agent-framework-hosted`** stack — agents
that run **inside** the Foundry Hosted Agents platform using its
built-in protocol surface (Responses, Invocations, conversation store,
isolation, identity), with **no `agent-framework-hosting` package
involved**.
| Aspect | `af-hosting/` (this directory) | `foundry-hosted-agents/` |
|---|---|---|
| Server stack | `agent-framework-hosting` + per-channel packages (`-responses`, `-invocations`, `-telegram`, `-activity-protocol`, `-entra`) | `agent-framework-hosted` only — the Foundry Hosted Agents runtime owns the HTTP surface |
| Channels other than Responses / Invocations | Yes — Telegram, Activity Protocol (Teams), Entra identity-linking | No — the platform exposes Responses + Invocations only |
| Run target | Local Hypercorn (`local_responses/`, `local_telegram/`, `local_identity_link/`); Hosted Agents *or* local (`foundry_hosted_agent/`) | Hosted Agents *or* local container; targets the Hosted Agents platform contract |
| When to pick this | You need extra channels (Telegram/Teams via Activity Protocol/…), custom hosting middleware, or want to run outside the Foundry runtime | You only need Responses/Invocations and want zero hosting boilerplate, leveraging the Foundry-managed surface |
`foundry_hosted_agent/` is the bridge sample: it uses the
`agent-framework-hosting` stack but is packaged so the Foundry Hosted
Agents platform can run it as one of its own.
See [`ARCHITECTURE.md`](./ARCHITECTURE.md) for the cross-sample story.
@@ -0,0 +1,419 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
*.env
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
[Aa][Rr][Mm]64[Ee][Cc]/
bld/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Build results on 'Bin' directories
**/[Bb]in/*
# Uncomment if you have tasks that rely on *.refresh files to move binaries
# (https://github.com/github/gitignore/pull/3736)
#!**/[Bb]in/*.refresh
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*.trx
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Approval Tests result files
*.received.*
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.idb
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
**/.paket/paket.exe
paket-files/
# FAKE - F# Make
**/.fake/
# CodeRush personal settings
**/.cr/personal
# Python Tools for Visual Studio (PTVS)
**/__pycache__/
*.pyc
# Cake - Uncomment if you are using it
#tools/**
#!tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
MSBuild_Logs/
# AWS SAM Build and Temporary Artifacts folder
.aws-sam
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
**/.mfractor/
# Local History for Visual Studio
**/.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
**/.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
.azure
@@ -0,0 +1,25 @@
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
# Build context for this Dockerfile is THIS folder (see ``azure.yaml`` ->
# ``services.<svc>.project: .``). The workspace packages this sample
# depends on are fetched from GitHub by ``uv sync`` (see the ``[tool.uv.sources]``
# git refs in ``pyproject.toml``). The build needs network access to GitHub
# during ``uv sync`` — no local vendoring step is required.
#
# ``Dockerfile.dockerignore`` (adjacent file, BuildKit) trims the upload to
# just the files COPYed below.
WORKDIR /app
COPY pyproject.toml ./
COPY app.py ./
# ``--no-dev`` skips the dev group (which only contains ``openai`` for
# ``call_server.py``). Locks fresh against the GitHub-hosted hosting
# packages declared in ``[tool.uv.sources]``.
RUN uv sync --no-dev
ENV PORT=8000
EXPOSE 8000
CMD ["uv", "run", "python", "app.py"]
@@ -0,0 +1,28 @@
# BuildKit per-Dockerfile ignore (sibling file: <Dockerfile>.dockerignore).
# Build context for this image is THIS folder. Trim everything except the
# files the Dockerfile actually COPYs.
# Local virtualenv & python caches.
.venv/
**/.venv/
**/__pycache__/
**/*.pyc
**/*.pyo
**/.pytest_cache/
**/.mypy_cache/
**/.ruff_cache/
# azd / git / IDE.
.azure/
.git/
.gitignore
.vscode/
.idea/
# Sample-specific files not needed at runtime.
README.md
call_server.py
agent.yaml
agent.manifest.yaml
azure.yaml
infra/
@@ -0,0 +1,136 @@
# foundry_hosted_agent — Responses + Invocations (Foundry Hosted Agents compatible)
Smallest end-to-end hosting sample. One Foundry-backed agent, two
channels, no human-chat surface — and that minimal shape is the whole
point: a host configured with at least the **Responses** and
**Invocations** channels under their default mount roots is
**runtime-compatible with the Foundry Hosted Agents platform**. The
same container image runs locally, behind any ASGI server, or as a
Hosted Agent — no protocol shim, no extra adapter.
| Route | Channel | Used by |
| ------------------------------ | -------------------- | ------------------------------------------- |
| `POST /responses` | `ResponsesChannel` | OpenAI Responses clients (`call_server.py`) |
| `POST /invocations/invoke` | `InvocationsChannel` | Host-native JSON envelope (Hosted Agents) |
## Conversation history
The agent is wired with `FoundryHostedAgentHistoryProvider` (from
`agent-framework-foundry-hosting`). When a Responses request supplies
`previous_response_id`, the channel uses it as the session id and the
provider fetches the prior turn chain directly from
`{FOUNDRY_PROJECT_ENDPOINT}/storage/...` using the same managed-identity
credential as the chat client. Locally (when `FOUNDRY_HOSTING_ENVIRONMENT`
is unset) it transparently falls back to an in-memory store, so the same
code runs in dev. Writes are a no-op — Foundry persists Responses turns
authoritatively as the runtime executes them.
For richer scenarios (custom tools, history providers, run hooks,
multicast, Telegram, Teams, identity linking) see
[`../local_telegram`](../local_telegram) and
[`../local_identity_link`](../local_identity_link).
## Layout
```
foundry_hosted_agent/
├── app.py # the host (ResponsesChannel + InvocationsChannel)
├── call_server.py # client: openai SDK / agent framework / FoundryAgent
├── agent.yaml # Foundry Hosted Agents minimal definition
├── agent.manifest.yaml # Foundry Hosted Agents full deployment manifest
├── azure.yaml # azd service config (build context = this folder)
├── Dockerfile # built from this folder; uv fetches deps from GitHub
├── Dockerfile.dockerignore # BuildKit allowlist that trims the context
├── pyproject.toml # depends on the hosting packages via GitHub git refs
└── README.md # this file
```
## Run locally
```bash
export FOUNDRY_PROJECT_ENDPOINT=https://<your-project>.services.ai.azure.com
export MODEL_DEPLOYMENT_NAME=gpt-4.1-mini
az login # any DefaultAzureCredential source
uv sync
uv run python app.py # binds 0.0.0.0:8000
```
The env var names match `agent.manifest.yaml` so the same shell
environment works for both local runs and Hosted Agent deployments.
## Call locally
```bash
uv sync --group dev
# OpenAI SDK pointed at the local /responses endpoint.
uv run python call_server.py --via openai "hello there"
# The same call via the Agent Framework Agent + OpenAIChatClient stack.
uv run python call_server.py --via af "hello there"
# Once deployed as a Hosted Agent: target the Foundry-managed endpoint.
export FOUNDRY_HOSTED_AGENT_NAME=agent-framework-hosting-sample
uv run python call_server.py --via foundry "hello there"
```
## Docker
The Docker build context is **this sample folder**. `pyproject.toml`
declares the in-tree `agent-framework-hosting*` packages via
[`[tool.uv.sources]` git refs](./pyproject.toml) pointing at the
``feature/python-hosting`` branch of
[microsoft/agent-framework](https://github.com/microsoft/agent-framework),
so `uv sync` inside the image fetches them directly. No vendoring step is
required — the build just needs network access to GitHub. Once the
hosting packages publish to PyPI you can drop the `[tool.uv.sources]`
overrides and rely on PyPI resolution.
```bash
# From this folder — context = `.` (sample folder).
DOCKER_BUILDKIT=1 docker build -t hosting-sample-hosted-agent .
docker run -p 8000:8000 \
-e FOUNDRY_PROJECT_ENDPOINT -e MODEL_DEPLOYMENT_NAME \
-e AZURE_CLIENT_ID -e AZURE_TENANT_ID -e AZURE_CLIENT_SECRET \
hosting-sample-hosted-agent
```
## Hosted Agent deployment
`azure.yaml` keeps `project: .` and uses `docker.remoteBuild: true` —
the remote builder receives only this sample folder and runs
`uv sync` to pull the hosting packages from GitHub.
The two YAMLs follow the same convention as the
[`foundry-hosted-agents/`](../../foundry-hosted-agents/) reference
samples — `agent.yaml` is the minimal kind/protocols/resources card,
`agent.manifest.yaml` is the full template + environment-variable +
model-resource binding used during deployment.
```bash
azd up # provisions infra/ + builds + pushes + deploys
azd deploy # rebuild + redeploy only
```
### Required Foundry RBAC
The container runs as the Hosted Agent's managed identity. That identity
needs permission to call the Foundry project's agent/Responses endpoints
— without it the call returns 401 ``PermissionDenied``. Grant the
**Azure AI Project Manager** role (or the more granular
``Microsoft.CognitiveServices/accounts/AIServices/agents/*`` data
actions) on the Foundry project to the Hosted Agent's managed identity.
See <https://aka.ms/FoundryPermissions> for the full role list.
### Health probe
The Foundry Hosted Agents runtime probes ``GET /readiness``;
``AgentFrameworkHost`` exposes that route automatically (returns
``200 ok``). No extra wiring needed.
The host code never imports anything Foundry-specific beyond the chat
client itself — swapping `FoundryChatClient` for `OpenAIChatClient` (or
any other client) flips this sample from a Hosted Agent target to a
non-Foundry deployment without touching the channels.
@@ -0,0 +1,31 @@
name: agent-framework-hosting-sample
description: >
Minimal Agent Framework multi-channel hosting sample (Responses + Invocations)
packaged for the Foundry Hosted Agents runtime. Demonstrates that an
``AgentFrameworkHost`` configured with the Responses and Invocations channels
under their default mounts is a drop-in Hosted Agent image — no protocol
shim, no Foundry-specific server.
metadata:
tags:
- Agent Framework
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Invocations Protocol
- Streaming
- Multi-Channel
template:
name: agent-framework-hosting-sample
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
- protocol: invocations
version: 1.0.0
environment_variables:
- name: MODEL_DEPLOYMENT_NAME
value: "{{MODEL_DEPLOYMENT_NAME}}"
resources:
- kind: model
id: gpt-5.4-nano
name: MODEL_DEPLOYMENT_NAME
@@ -0,0 +1,26 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: agent-framework-hosting-sample
description: |
Minimal Agent Framework multi-channel hosting sample (Responses + Invocations) packaged for the Foundry Hosted Agents runtime. Demonstrates that an ``AgentFrameworkHost`` configured with the Responses and Invocations channels under their default mounts is a drop-in Hosted Agent image — no protocol shim, no Foundry-specific server.
metadata:
tags:
- Agent Framework
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Invocations Protocol
- Streaming
- Multi-Channel
protocols:
- protocol: responses
version: 1.0.0
- protocol: invocations
version: 1.0.0
resources:
cpu: "1"
memory: 2Gi
environment_variables:
- name: MODEL_DEPLOYMENT_NAME
value: gpt-5.4-nano
@@ -0,0 +1,185 @@
# Copyright (c) Microsoft. All rights reserved.
"""Smallest hosting sample — Responses + Invocations only.
This sample is intentionally minimal and is **runtime-compatible with the
Foundry Hosted Agents platform**: a host that exposes the Responses and
Invocations channels under their default mount roots can be packaged as a
container image and deployed to Foundry Hosted Agents without any protocol
shim. The same image runs locally, behind any ASGI server, or as a Hosted
Agent.
History
-------
The agent uses :class:`FoundryHostedAgentHistoryProvider` so that conversation
history is loaded from the Foundry Hosted Agent storage backend when the
container runs inside Foundry. When ``previous_response_id`` is supplied on
an incoming Responses request, the channel routes it through to the
provider as the ``session_id``, and the provider fetches the prior turn
chain from ``{FOUNDRY_PROJECT_ENDPOINT}/storage/...``. Locally
(``FOUNDRY_HOSTING_ENVIRONMENT`` unset) the provider falls back to an
in-memory store so the same code runs in dev.
Setup
-----
- ``FOUNDRY_PROJECT_ENDPOINT`` — Foundry project endpoint URL.
- ``MODEL_DEPLOYMENT_NAME`` — model deployment name (the same env var
the Foundry Hosted Agents manifest binds via the ``model`` resource —
see ``agent.manifest.yaml``).
- ``FOUNDRY_HOSTING_ENVIRONMENT`` — set automatically by the Hosted Agents
runtime; signals the history provider to talk to the Foundry storage API
instead of the local in-memory fallback.
- ``APPLICATIONINSIGHTS_CONNECTION_STRING`` — when present, the sample
wires Azure Monitor OpenTelemetry export at import time. Foundry Hosted
Agents inject this when an Application Insights resource is bound to
the project; locally it's optional.
Auth uses ``DefaultAzureCredential`` so any standard Azure auth chain
works (``az login`` locally, managed identity in Hosted Agents,
``AZURE_*`` env vars in CI, ...).
Run
---
- Local: ``python app.py`` (binds ``0.0.0.0:8000``)
- ASGI: ``hypercorn app:app --bind 0.0.0.0:8000``
- Docker: ``docker build -t hosting-sample-hosted-agent . && \\
docker run -p 8000:8000 \\
-e FOUNDRY_PROJECT_ENDPOINT -e MODEL_DEPLOYMENT_NAME \\
hosting-sample-hosted-agent``
- Hosted Agent: build & push the image, then deploy via ``agent.yaml`` /
``agent.manifest.yaml`` in this folder.
Routes
------
- ``POST /responses`` — OpenAI Responses-shaped surface.
- ``POST /invocations/invoke`` — host-native JSON envelope.
"""
from __future__ import annotations
import logging
import os
from agent_framework import Agent
from agent_framework.observability import enable_instrumentation
from agent_framework_foundry import FoundryChatClient
from agent_framework_foundry_hosting import (
FoundryHostedAgentHistoryProvider,
foundry_response_id,
)
from agent_framework_hosting import AgentFrameworkHost
from agent_framework_hosting_invocations import InvocationsChannel
from agent_framework_hosting_responses import ResponsesChannel
from azure.identity.aio import DefaultAzureCredential
# Configure root logging early so library log records (in particular
# ``agent_framework_foundry_hosting._history_provider``) are captured by
# the container's stderr stream and surfaced in the Foundry portal /
# Azure Monitor. ``LOG_LEVEL`` overrides this for production tightening.
logging.basicConfig(
level=os.environ.get("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
# Quiet noisy transports unless explicitly cranked up.
for _noisy in (
"httpx",
"httpcore",
"azure.core.pipeline.policies.http_logging_policy",
"urllib3",
):
logging.getLogger(_noisy).setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
def _configure_observability() -> None:
"""Wire Azure Monitor OpenTelemetry when a connection string is present.
Foundry Hosted Agents inject ``APPLICATIONINSIGHTS_CONNECTION_STRING``
into the container at runtime when an Application Insights resource is
bound to the project. We honor the same env var locally so the same
code path lights up in both environments. When the var is absent
(typical local dev without an AI binding) we silently skip — the host
still serves traffic, just without OTel export.
"""
conn_str = os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING")
if not conn_str:
logger.info(
"APPLICATIONINSIGHTS_CONNECTION_STRING not set — skipping Azure Monitor OpenTelemetry configuration.",
)
return
# Imported lazily so the sample still starts when the optional
# ``azure-monitor-opentelemetry`` dependency isn't installed (e.g. an
# ultra-thin local dev image stripped of observability extras).
from azure.monitor.opentelemetry import configure_azure_monitor
configure_azure_monitor(connection_string=conn_str)
logger.info("Azure Monitor OpenTelemetry configured.")
def build_host() -> AgentFrameworkHost:
# Single credential is shared by the chat client and the history
# provider so we only authenticate (and refresh tokens) once.
credential = DefaultAzureCredential()
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
agent = Agent(
client=FoundryChatClient(
project_endpoint=project_endpoint,
model=os.environ["MODEL_DEPLOYMENT_NAME"],
credential=credential,
),
name="HostedAgentSample",
instructions="You are called Jarvis, a friendly assistant. Keep answers brief.",
# Loads history from Foundry storage when running inside a Hosted
# Agent (FOUNDRY_HOSTING_ENVIRONMENT set); falls back to an in-
# memory store for local dev.
context_providers=[
FoundryHostedAgentHistoryProvider(
credential=credential,
endpoint=project_endpoint,
),
],
)
return AgentFrameworkHost(
target=agent,
channels=[
# Mint Foundry-storage-compatible response ids
# (``caresp_{18charPartitionKey}{32charEntropy}``). The
# Foundry storage backend partitions records by extracting
# this segment from the id; free-form ``resp_<uuid>`` ids
# are rejected with an opaque ``HTTP 500 server_error``.
ResponsesChannel(response_id_factory=foundry_response_id),
InvocationsChannel(),
],
)
# `app` is the canonical ASGI surface — hand it to any ASGI server, or let
# the Foundry Hosted Agents runtime pick it up via the standard entry point.
# Observability is configured at import time so trace/log export is wired
# before the host starts handling requests. Per-request Foundry isolation
# (the platform-injected ``x-agent-{user,chat}-isolation-key`` headers)
# is read by the host's installed ASGI middleware off every inbound HTTP
# request and lifted into a contextvar that
# :class:`FoundryHostedAgentHistoryProvider` consults on each storage call.
# Multi-turn persistence works out of the box in both local dev and the
# Hosted Agents container — no manual middleware wiring needed.
_configure_observability()
enable_instrumentation(enable_sensitive_data=True)
app = build_host().app
if __name__ == "__main__":
# Serve the host's ASGI app directly. The Foundry isolation headers
# are read by the host's installed ASGI middleware and threaded
# through the storage provider via a contextvar; nothing extra to wire.
import asyncio
import hypercorn.asyncio
import hypercorn.config
config = hypercorn.config.Config()
config.bind = [f"0.0.0.0:{int(os.environ.get('PORT', '8000'))}"]
asyncio.run(hypercorn.asyncio.serve(app, config)) # type: ignore[arg-type]
@@ -0,0 +1,32 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
requiredVersions:
extensions:
azure.ai.agents: '>=0.1.0-preview'
name: ai-foundry-starter-basic
services:
agent-framework-hosting-sample:
project: .
host: azure.ai.agent
language: docker
docker:
remoteBuild: true
config:
container:
resources:
cpu: "1"
memory: 2Gi
scale:
maxReplicas: 1
deployments:
- model:
format: OpenAI
name: gpt-5.4-nano
version: "2026-03-17"
name: gpt-5.4-nano
sku:
capacity: 250
name: GlobalStandard
infra:
provider: bicep
path: ./infra
@@ -0,0 +1,126 @@
# Copyright (c) Microsoft. All rights reserved.
"""Call the foundry_hosted_agent server three ways.
The foundry_hosted_agent host exposes ``POST /responses`` (OpenAI Responses-shaped) and
``POST /invocations/invoke`` (host-native), and that minimal contract is
**runtime-compatible with the Foundry Hosted Agents platform** — so the same
agent code that calls the local server also calls the same image deployed
as a Hosted Agent.
Modes
-----
``--via openai`` (default)
Plain ``openai`` SDK against the local ``/responses``. Uses
``api_key="not-needed"`` because the local sample has no auth.
``--via af``
Agent Framework ``Agent`` wrapping ``OpenAIChatClient`` pointed at the
local ``BASE_URL``. ``OpenAIChatClient`` already speaks the Responses
surface natively.
``--via foundry``
Agent Framework ``FoundryAgent`` against a Hosted Agent that this image
has been deployed as. Requires::
FOUNDRY_PROJECT_ENDPOINT=https://<project>.services.ai.azure.com
FOUNDRY_HOSTED_AGENT_NAME=<hosted-agent-name>
Auth uses ``AzureCliCredential`` (run ``az login`` first).
Start the server first (in another shell)::
uv run python app.py
Then::
uv run python call_server.py "Who are you?"
uv run python call_server.py --via af "What's the weather in Seattle?"
FOUNDRY_PROJECT_ENDPOINT=... FOUNDRY_HOSTED_AGENT_NAME=... \\
uv run python call_server.py --via foundry "Who are you?"
"""
from __future__ import annotations
import argparse
import asyncio
import os
from agent_framework import Agent
from agent_framework_foundry import FoundryAgent
from agent_framework_openai import OpenAIChatClient
from azure.identity.aio import AzureCliCredential
from openai import OpenAI
# Bare server origin — the OpenAI SDK / OpenAIChatClient append ``/responses`` themselves.
BASE_URL = "http://127.0.0.1:8000"
def call_via_openai_sdk(prompt: str) -> None:
client = OpenAI(base_url=BASE_URL, api_key="not-needed")
response = client.responses.create(model="agent", input=prompt)
print(f"User: {prompt}")
print(f"Agent: {response.output_text}")
async def call_via_agent_framework(prompt: str) -> None:
# Agent + OpenAIChatClient(base_url=...) is the Agent Framework way to
# talk to any Responses-shaped endpoint — including foundry_hosted_agent's `/responses`.
chat_client = OpenAIChatClient(base_url=BASE_URL, api_key="not-needed", model_id="agent")
agent = Agent(client=chat_client)
result = await agent.run(prompt)
print(f"User: {prompt}")
print(f"Agent: {result.text}")
async def call_via_foundry_hosted_agent(prompt: str) -> None:
# Once foundry_hosted_agent's image is deployed as a Foundry Hosted Agent, FoundryAgent
# keyed on ``agent_name`` is the AF-native client. The agent's runtime is
# the very same Responses + Invocations contract — Foundry just hosts it.
project_endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT")
if not project_endpoint:
raise SystemExit(
"FOUNDRY_PROJECT_ENDPOINT must be set; e.g. https://<project>.services.ai.azure.com/api/projects/agents"
)
agent_name = os.environ.get("FOUNDRY_HOSTED_AGENT_NAME", "agent-framework-hosting-sample")
# Optional: continue a prior conversation by passing FOUNDRY_HOSTED_SESSION_ID.
session_id = os.environ.get("FOUNDRY_HOSTED_SESSION_ID")
async with AzureCliCredential() as credential:
agent = FoundryAgent(
project_endpoint=project_endpoint,
agent_name=agent_name,
credential=credential,
allow_preview=True,
)
if session_id:
session = agent.get_session(service_session_id=session_id)
result = await agent.run(prompt, session=session)
else:
result = await agent.run(prompt)
print(f"User: {prompt}")
print(f"Agent: {result.text}")
print(f"Session ID (for history continuity): {result.response_id}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
"--via",
choices=("openai", "af", "foundry"),
default="openai",
help="Calling client to use.",
)
parser.add_argument("prompt", nargs="*")
args = parser.parse_args()
prompt = " ".join(args.prompt) or "Who are you?"
if args.via == "openai":
call_via_openai_sdk(prompt)
elif args.via == "af":
asyncio.run(call_via_agent_framework(prompt))
else:
asyncio.run(call_via_foundry_hosted_agent(prompt))
if __name__ == "__main__":
main()
@@ -0,0 +1,28 @@
[project]
name = "agent-framework-hosting-sample-hosted-agent"
version = "0.0.1"
description = "Hosted-Agent-compatible hosting sample (Responses + Invocations)."
requires-python = ">=3.10"
dependencies = [
"agent-framework-foundry",
"agent-framework-foundry-hosting",
"agent-framework-hosting",
"agent-framework-hosting-invocations",
"agent-framework-hosting-responses",
"azure-identity",
"aiohttp>=3.13.5",
"hypercorn>=0.17",
"azure-monitor-opentelemetry>=1.6",
]
[dependency-groups]
dev = ["openai>=1.99"]
[tool.uv]
package = false
[tool.uv.sources]
agent-framework-foundry-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/foundry_hosting" }
agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting" }
agent-framework-hosting-invocations = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-invocations" }
agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-responses" }
@@ -0,0 +1,67 @@
# local_identity_link — every channel, plus identity linking
The full surface: Responses + Invocations + Telegram + Activity Protocol (Teams) + the Entra
identity-link sidecar. The Entra channel exposes
`/auth/start` + `/auth/callback` so users on Telegram (or any non-Entra
channel) can bind their per-channel id to a stable `entra:<oid>` isolation
key. Channel run-hooks then rewrite incoming requests to use the linked
key, so a chat started on Telegram and a chat started on Teams that both
resolve to the same Entra user share one history.
## Run
```bash
export FOUNDRY_PROJECT_ENDPOINT=https://<your-project>.services.ai.azure.com
export FOUNDRY_MODEL=gpt-4o
export TELEGRAM_BOT_TOKEN=...
# Entra app registration (confidential client):
export ENTRA_TENANT_ID=...
export ENTRA_CLIENT_ID=...
export ENTRA_CLIENT_SECRET=... # or:
# export ENTRA_CERTIFICATE_PATH=./teams-bot.pem
export PUBLIC_BASE_URL=https://<public-host> # used to mint redirect_uri
# Teams (optional — same tenant):
export TEAMS_APP_ID=...
export TEAMS_APP_PASSWORD=...
az login
uv sync
uv run hypercorn app:app \
--bind 0.0.0.0:8000 \
--workers 4
```
## Identity link
Register `https://<public-host>/auth/callback` as the redirect URI on your
Entra app, then visit (replace ``<chat_id>`` with the Telegram numeric
chat id):
```
https://<public-host>/auth/start?channel=telegram&id=<chat_id>
```
After sign-in, subsequent Telegram messages from that chat resolve to the
linked Entra user.
## Call locally
```bash
uv sync --group dev
# Default: post a Responses request as `local-dev`.
uv run python call_server.py "What is the weather in Tokyo?"
# Resume any session by id, including a Telegram one (works because
# the Telegram run-hook writes sessions under telegram:<chat_id>):
uv run python call_server.py --previous-response-id telegram:8741188429 "What did we discuss?"
# Multicast to a Telegram chat in parallel with the local response:
uv run python call_server.py --telegram-chat-id 8741188429 "Heads up."
```
> This sample is **local-only** — it shows the `agent-framework-hosting`
> server stack as a standalone process. For a Foundry-Hosted-Agents-compatible
> packaging (Dockerfile + `agent.yaml` + `azure.yaml`), see
> [`foundry_hosted_agent/`](../foundry_hosted_agent).
@@ -0,0 +1,395 @@
# Copyright (c) Microsoft. All rights reserved.
"""Complete multi-channel hosting sample with unified Entra ID identity.
Wires every built-in channel onto a single ``AgentFrameworkHost`` and
demonstrates a pattern for collapsing per-channel identifiers into a single
**Microsoft Entra ID** (object id) key so a user's history follows them
across surfaces.
Identity resolution
-------------------
Each request is bucketed under one ``isolation_key`` for ``FileHistoryProvider``:
- **Teams** is the source of truth. Inbound activities carry the user's
``aadObjectId``; we promote it to ``entra:<oid>`` in the Teams ``run_hook``.
- **Telegram** has no built-in OAuth identity. Users link their chat to
their Entra ID by sending ``/link``; the bot replies with a one-shot
authorize URL served by the host's ``EntraIdentityLinkChannel``. After the
OAuth callback the mapping ``telegram:<chat_id> → entra:<oid>`` is
persisted to ``identity_links.json`` and every later Telegram turn is
bucketed under the user's Entra key.
- **Responses API** callers can pass ``entra_oid`` directly (top-level or
in ``metadata``), or pass ``safety_identifier`` and rely on the same
store (``responses:<safety_id> → entra:<oid>``). Otherwise we fall back
to ``responses:<safety_id>``.
Required environment
--------------------
- ``FOUNDRY_PROJECT_ENDPOINT`` / ``FOUNDRY_MODEL`` — agent backing.
- ``TELEGRAM_BOT_TOKEN`` — required to enable the Telegram channel.
- ``TEAMS_APP_ID`` / ``TEAMS_APP_PASSWORD`` — optional; without them the
Teams channel runs in dev mode (Bot Framework Emulator only).
- ``ENTRA_TENANT_ID`` / ``ENTRA_CLIENT_ID`` plus **either**
``ENTRA_CLIENT_SECRET`` **or** ``ENTRA_CERT_PATH``
(+ optional ``ENTRA_CERT_PASSWORD``) — required to enable the ``/link``
flow. The app's redirect URI must be registered as
``{PUBLIC_BASE_URL}/auth/callback`` in your Entra app.
- ``PUBLIC_BASE_URL`` — externally reachable base of this host (e.g.
``https://my-host.example.com``). Defaults to ``http://localhost:8000``.
Run
---
This module exposes ``app`` as the canonical ASGI surface. Recommended
production launch is **Hypercorn**::
hypercorn app:app --bind 0.0.0.0:8000 --workers 4
The ``__main__`` block below uses ``host.serve(...)`` (single-process
Hypercorn) as a local-dev fallback.
"""
from __future__ import annotations
import logging
import os
from collections.abc import Mapping
from dataclasses import replace
from pathlib import Path
from typing import Annotated, Any
from agent_framework import Agent, FileHistoryProvider, tool
from agent_framework_foundry import FoundryChatClient
from agent_framework_hosting import (
AgentFrameworkHost,
Channel,
ChannelCommand,
ChannelCommandContext,
ChannelRequest,
ChannelSession,
)
from agent_framework_hosting_activity_protocol import ActivityProtocolChannel
from agent_framework_hosting_entra import (
EntraIdentityLinkChannel,
EntraIdentityStore,
entra_isolation_key,
)
from agent_framework_hosting_invocations import InvocationsChannel
from agent_framework_hosting_responses import ResponsesChannel
from agent_framework_hosting_telegram import TelegramChannel
from azure.identity.aio import DefaultAzureCredential
logger = logging.getLogger("agent_framework.hosting.complete_app")
SESSIONS_DIR = Path(__file__).resolve().parent / "storage" / "sessions"
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
IDENTITY_STORE_PATH = Path(__file__).resolve().parent / "storage" / "identity_links.json"
# --------------------------------------------------------------------------- #
# Tools
# --------------------------------------------------------------------------- #
@tool(approval_mode="never_require")
def lookup_weather(
location: Annotated[str, "The city to look up weather for."],
) -> str:
"""Return a deterministic weather report for a city."""
reports = {
"Seattle": "Seattle is rainy with a high of 13°C.",
"Amsterdam": "Amsterdam is cloudy with a high of 16°C.",
"Tokyo": "Tokyo is clear with a high of 22°C.",
}
return reports.get(location, f"{location} is sunny with a high of 20°C.")
# --------------------------------------------------------------------------- #
# Run hooks: collapse per-channel identifiers down to a single Entra ID key
# --------------------------------------------------------------------------- #
def _replace_session(request: ChannelRequest, isolation_key: str) -> ChannelRequest:
return replace(request, session=ChannelSession(isolation_key=isolation_key))
def make_activity_hook() -> Any:
"""Promote ``aadObjectId`` from the inbound Activity to ``entra:<oid>``.
The Activity Protocol channel is treated as the **primary** identity
source for Teams traffic: every authenticated Teams user has an Entra
object id, and we trust it directly without consulting the link store.
"""
def _hook(
request: ChannelRequest,
*,
protocol_request: Mapping[str, Any] | None = None,
**_: object,
) -> ChannelRequest:
activity = protocol_request or {}
from_ = activity.get("from") if isinstance(activity, Mapping) else None
oid = from_.get("aadObjectId") if isinstance(from_, Mapping) else None
if oid:
return _replace_session(request, entra_isolation_key(oid))
# Unauthenticated channels (web chat, emulator) — fall back to the
# per-conversation key the channel already set.
return request
return _hook
def make_telegram_hook(store: EntraIdentityStore) -> Any:
"""Resolve identity then bump reasoning effort.
The reasoning bump applies to **every** Telegram request — linked or
not — so the high-effort preset isn't silently lost the moment a
user runs ``/link`` (which is the headline feature of this sample).
Identity resolution and option mutation are separate concerns: we
swap the session if a link exists, then upgrade the options on the
way out either way.
"""
def _hook(request: ChannelRequest, **_: object) -> ChannelRequest:
chat_id = request.attributes.get("chat_id")
if chat_id is not None:
linked = store.lookup(f"telegram:{chat_id}")
if linked is not None:
request = _replace_session(request, linked)
# Bump reasoning effort regardless of identity (linked or not).
options = dict(request.options or {})
options["reasoning"] = {"effort": "high", "summary": "detailed"}
return replace(request, options=options)
return _hook
def make_responses_hook(store: EntraIdentityStore) -> Any:
"""Same identity resolution as Telegram/Teams, plus the usual option scrub.
Resolution order:
1. Body ``entra_oid`` (top-level or in ``metadata``) — a caller already
knows the user's Entra id.
2. ``safety_identifier`` (or legacy ``user``) looked up in the link
store as ``responses:<id>``.
3. Fallback ``responses:<safety_id>``.
.. WARNING::
DEV ONLY. The ``entra_oid`` shortcut treats a client-supplied
identity claim as authoritative with **no token verification**:
any Responses caller can claim to be any user and read that
user's history bucket. Production deployments must either:
- Drop this shortcut entirely and rely on ``safety_identifier``
+ the link store (i.e. force every caller through the OAuth
identity-link flow), or
- Add a JWT validator that verifies an inbound Authorization
header, extracts the verified ``oid`` claim, and feeds *that*
into ``entra_isolation_key`` — never trust a body field for
identity in a multi-tenant deployment.
This shortcut exists only so the sample's smoke tests can pin
an isolation key without spinning up an Entra app registration.
"""
def _hook(
request: ChannelRequest,
*,
protocol_request: Mapping[str, Any] | None = None,
**_: object,
) -> ChannelRequest:
options = dict(request.options or {})
options.pop("temperature", None)
options.pop("store", None)
body = protocol_request or {}
metadata = body.get("metadata") if isinstance(body.get("metadata"), dict) else {}
# WARNING (DEV ONLY): client-supplied entra_oid is trusted with
# NO verification. Production code must verify a JWT instead.
explicit_oid = body.get("entra_oid") or metadata.get("entra_oid")
safety_id = body.get("safety_identifier") or body.get("user") or "anonymous"
if explicit_oid:
isolation_key = entra_isolation_key(explicit_oid)
else:
isolation_key = store.lookup(f"responses:{safety_id}") or f"responses:{safety_id}"
return replace(
request,
session=ChannelSession(isolation_key=isolation_key),
options=options or None,
)
return _hook
# --------------------------------------------------------------------------- #
# Telegram commands
# --------------------------------------------------------------------------- #
def make_commands(
host_ref: dict[str, AgentFrameworkHost],
store: EntraIdentityStore,
linker_ref: dict[str, EntraIdentityLinkChannel | None],
) -> list[ChannelCommand]:
def _telegram_key(ctx: ChannelCommandContext) -> str:
chat_id = ctx.request.attributes.get("chat_id")
return f"telegram:{chat_id}"
def _isolation_for(ctx: ChannelCommandContext) -> str:
# Honour any existing link so /new resets the right bucket.
return store.lookup(_telegram_key(ctx)) or _telegram_key(ctx)
async def handle_start(ctx: ChannelCommandContext) -> None:
await ctx.reply(
"Hi! I'm a multi-channel agent.\nCommands: /link, /unlink, /new, /whoami, /weather <city>, /help."
)
async def handle_help(ctx: ChannelCommandContext) -> None:
await ctx.reply(
"/link — bind this chat to your Entra ID for shared history\n"
"/unlink — unbind this chat\n"
"/new — start a fresh conversation\n"
"/whoami — show your isolation key\n"
"/weather <city> — call the weather tool directly\n"
"/help — this message"
)
async def handle_link(ctx: ChannelCommandContext) -> None:
linker = linker_ref.get("linker")
if linker is None:
await ctx.reply(
"Identity linking is not configured on this host. "
"Set ENTRA_TENANT_ID, ENTRA_CLIENT_ID, and either "
"ENTRA_CLIENT_SECRET or ENTRA_CERTIFICATE_PATH."
)
return
chat_id = ctx.request.attributes.get("chat_id")
if chat_id is None:
# Without a chat_id we'd format "telegram:None" into the
# authorize URL, OAuth would complete, and the store would
# gain a poisoned `telegram:None` entry that any later
# chat_id-less message would collapse onto. Refuse instead.
await ctx.reply("Couldn't determine your Telegram chat id; please retry from a 1:1 chat with the bot.")
return
url = linker.authorize_url_for("telegram", str(chat_id))
await ctx.reply("Open this link to bind this chat to your Microsoft account:\n" + url)
async def handle_unlink(ctx: ChannelCommandContext) -> None:
await store.unlink(_telegram_key(ctx))
await ctx.reply("This chat is no longer linked. New messages will use the chat-only key.")
async def handle_new(ctx: ChannelCommandContext) -> None:
host_ref["host"].reset_session(_isolation_for(ctx))
await ctx.reply("New session started. Previous history is cleared.")
async def handle_whoami(ctx: ChannelCommandContext) -> None:
key = _isolation_for(ctx)
if key.startswith("entra:"):
await ctx.reply(f"This chat is linked. Isolation key: {key}")
else:
await ctx.reply(f"This chat is not linked to an Entra ID. Isolation key: {key}\nSend /link to bind it.")
async def handle_weather(ctx: ChannelCommandContext) -> None:
command_text = ctx.request.input if isinstance(ctx.request.input, str) else ""
_, _, location = command_text.partition(" ")
location = location.strip() or "Seattle"
await ctx.reply(lookup_weather(location=location))
return [
ChannelCommand("start", "Introduce the bot", handle_start),
ChannelCommand("help", "List available commands", handle_help),
ChannelCommand("link", "Bind this chat to your Microsoft account", handle_link),
ChannelCommand("unlink", "Unbind this chat from any Microsoft account", handle_unlink),
ChannelCommand("new", "Start a new session for this chat", handle_new),
ChannelCommand("whoami", "Show the isolation key for this chat", handle_whoami),
ChannelCommand("weather", "Call the weather tool: /weather <city>", handle_weather),
]
# --------------------------------------------------------------------------- #
# Host wiring
# --------------------------------------------------------------------------- #
def build_host() -> AgentFrameworkHost:
agent = Agent(
client=FoundryChatClient(credential=DefaultAzureCredential()),
name="WeatherAgent",
instructions=(
"You are a friendly weather assistant. Use the lookup_weather tool "
"for any weather question and answer in one short sentence."
),
tools=[lookup_weather],
context_providers=[FileHistoryProvider(SESSIONS_DIR)],
default_options={"store": False},
)
store = EntraIdentityStore(IDENTITY_STORE_PATH)
# Optional Entra-OAuth identity linker. Pick exactly one credential mode:
# ENTRA_CLIENT_SECRET *or* ENTRA_CERT_PATH (+ optional ENTRA_CERT_PASSWORD).
# When unconfigured, /link tells the user the feature is disabled and the
# host runs without a linker.
tenant_id = os.environ.get("ENTRA_TENANT_ID")
client_id = os.environ.get("ENTRA_CLIENT_ID")
client_secret = os.environ.get("ENTRA_CLIENT_SECRET")
cert_path = os.environ.get("ENTRA_CERTIFICATE_PATH")
cert_password_env = os.environ.get("ENTRA_CERTIFICATE_PASSWORD")
public_base_url = os.environ.get("PUBLIC_BASE_URL", "http://localhost:8000")
linker: EntraIdentityLinkChannel | None = None
if tenant_id and client_id and (client_secret or cert_path):
linker = EntraIdentityLinkChannel(
store=store,
tenant_id=tenant_id,
client_id=client_id,
client_secret=client_secret,
certificate_path=cert_path,
certificate_password=cert_password_env.encode() if cert_password_env else None,
public_base_url=public_base_url,
)
host_ref: dict[str, AgentFrameworkHost] = {}
linker_ref: dict[str, EntraIdentityLinkChannel | None] = {"linker": linker}
channels: list[Channel] = [
ResponsesChannel(run_hook=make_responses_hook(store)),
InvocationsChannel(),
ActivityProtocolChannel(
app_id=os.environ.get("TEAMS_APP_ID"),
tenant_id=os.environ.get("TEAMS_TENANT_ID", "botframework.com"),
# Use either a client secret OR a certificate. Cert is required
# for tenants that disallow secrets — see the package README for
# an `openssl` recipe to generate one.
app_password=os.environ.get("TEAMS_APP_PASSWORD"),
certificate_path=os.environ.get("TEAMS_CERT_PATH"),
certificate_password=(
os.environ["TEAMS_CERT_PASSWORD"].encode() if os.environ.get("TEAMS_CERT_PASSWORD") else None
),
run_hook=make_activity_hook(),
),
TelegramChannel(
bot_token=os.environ["TELEGRAM_BOT_TOKEN"],
webhook_url=os.environ.get("TELEGRAM_WEBHOOK_URL"),
secret_token=os.environ.get("TELEGRAM_WEBHOOK_SECRET"),
parse_mode="Markdown",
commands=make_commands(host_ref, store, linker_ref),
run_hook=make_telegram_hook(store),
),
]
if linker is not None:
channels.append(linker)
host = AgentFrameworkHost(target=agent, channels=channels, debug=True)
host_ref["host"] = host
return host
app = build_host().app
if __name__ == "__main__":
build_host().serve(host="0.0.0.0", port=int(os.environ.get("PORT", "8000")))
@@ -0,0 +1,72 @@
# Copyright (c) Microsoft. All rights reserved.
"""Local client for the **complete** server (``app.py`` in this folder).
Demonstrates the two most distinctive flows the complete sample adds on top
of the advanced sample:
1. **Identity-linked Telegram resume.** Pass ``--previous-response-id
telegram:<chat_id>`` to resume a Telegram chat's history through the
Responses endpoint — this only works once the user has linked their
Telegram chat to their Entra account via the
``EntraIdentityLinkChannel`` (visit ``/auth/start?channel=telegram&id=...``
in the browser first).
2. **Multicast via ``response_target``.** Pass ``--telegram-chat-id`` to
have the host fan out the agent reply to a Telegram chat in addition
to returning it on the local wire. Drop ``--include-originating`` to
send only to Telegram and have the local response reduced to a small
acknowledgement.
Start the server first (in another shell)::
cd local_identity_link && uv run python app.py
Then::
python call_server.py "What is the weather in Tokyo?"
python call_server.py --previous-response-id telegram:8741188429 "What did we discuss?"
python call_server.py --telegram-chat-id 8741188429 "Heads up, sending to your phone too."
"""
from __future__ import annotations
import argparse
from openai import OpenAI
BASE_URL = "http://127.0.0.1:8000"
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--safety-identifier", default="local-dev")
parser.add_argument("--previous-response-id", default=None)
parser.add_argument("--telegram-chat-id", default=None)
parser.add_argument("--include-originating", action="store_true", default=True)
parser.add_argument("prompt", nargs="*")
args = parser.parse_args()
prompt = " ".join(args.prompt) or "What is the weather in Seattle?"
extra_body: dict[str, object] = {}
if args.telegram_chat_id is not None:
targets: list[str] = []
if args.include_originating:
targets.append("originating")
targets.append(f"telegram:{args.telegram_chat_id}")
extra_body["response_target"] = targets
client = OpenAI(base_url=BASE_URL, api_key="not-needed")
response = client.responses.create(
model="agent",
input=prompt,
safety_identifier=args.safety_identifier,
previous_response_id=args.previous_response_id,
extra_body=extra_body or None,
)
print(f"User: {prompt}")
print(f"Agent: {response.output_text}")
if __name__ == "__main__":
main()
@@ -0,0 +1,32 @@
[project]
name = "agent-framework-hosting-sample-complete"
version = "0.0.1"
description = "Complete multi-channel hosting sample (Responses + Invocations + Telegram + Activity Protocol + Entra identity-link)."
requires-python = ">=3.10"
dependencies = [
"agent-framework-foundry",
"agent-framework-hosting",
"agent-framework-hosting-activity-protocol",
"agent-framework-hosting-entra",
"agent-framework-hosting-invocations",
"agent-framework-hosting-responses",
"agent-framework-hosting-telegram",
"azure-identity",
"hypercorn>=0.17",
"httpx>=0.27",
"aiohttp>=3.13.5",
]
[dependency-groups]
dev = ["openai>=1.99"]
[tool.uv]
package = false
[tool.uv.sources]
agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting" }
agent-framework-hosting-activity-protocol = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-activity-protocol" }
agent-framework-hosting-entra = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-entra" }
agent-framework-hosting-invocations = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-invocations" }
agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-responses" }
agent-framework-hosting-telegram = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-telegram" }
@@ -0,0 +1,49 @@
# local_responses — Responses-only with a settings-altering hook
The smallest end-to-end `agent-framework-hosting` shape: one Foundry
agent with a `@tool`, one `ResponsesChannel`, one `run_hook`. Useful as
the entry-point sample for understanding the **channel run-hook** seam
without any multi-channel or identity-link concerns.
What the run hook demonstrates:
- **Strips** caller-supplied `temperature` / `store` so the host owns
those settings.
- **Forces** a `reasoning` preset (`effort=medium`, `summary=auto`) on
every turn — caller-side overrides are ignored.
`app:app` is a module-level Starlette ASGI app; recommended local launch
is Hypercorn.
## Run
```bash
export FOUNDRY_PROJECT_ENDPOINT=https://<your-project>.services.ai.azure.com
export FOUNDRY_MODEL=gpt-5.4-nano
az login
uv sync
uv run hypercorn app:app --bind 0.0.0.0:8000
```
Single-process for quick iteration:
```bash
uv run python app.py
```
## Call locally
```bash
uv sync --group dev
# Plain call:
uv run python call_server.py "What is the weather in Tokyo?"
# Continue an existing conversation by its `response.id`:
uv run python call_server.py --previous-response-id <response-id> "And in Seattle?"
```
> This sample is **local-only** — no Dockerfile, no Foundry packaging.
> For a Foundry-Hosted-Agents-compatible packaging see
> [`../foundry_hosted_agent`](../foundry_hosted_agent).
@@ -0,0 +1,113 @@
# Copyright (c) Microsoft. All rights reserved.
"""Minimal Responses-only hosting sample.
Single agent with one ``@tool`` (``lookup_weather``), single channel
(``ResponsesChannel``), one ``run_hook`` that demonstrates the
settings-mutation seam over caller-supplied options.
What the hook does
------------------
On every Responses request the hook receives the ``ChannelRequest`` that
the channel built from the inbound HTTP body. It:
- strips ``store`` (this agent owns persistence) and ``temperature``
(the configured model may not honor it),
- forces a ``reasoning`` effort + summary preset so the deployed surface
is consistent regardless of what the caller sent.
The hook is the documented escape hatch over the uniform
``ChannelRequest`` envelope.
Run
---
``app`` is a module-level Starlette ASGI app. Recommended local launch::
uv sync
az login
export FOUNDRY_PROJECT_ENDPOINT=https://<your-project>.services.ai.azure.com
export FOUNDRY_MODEL=gpt-5.4-nano
uv run hypercorn app:app --bind 0.0.0.0:8000
Or use the ``__main__`` block (single-process Hypercorn) for quick
iteration::
uv run python app.py
Then call it::
uv run python call_server.py "What is the weather in Tokyo?"
"""
from __future__ import annotations
import os
from dataclasses import replace
from pathlib import Path
from random import randint
from typing import Annotated
from agent_framework import Agent, FileHistoryProvider, tool
from agent_framework_foundry import FoundryChatClient
from agent_framework_hosting import AgentFrameworkHost, ChannelRequest
from agent_framework_hosting_responses import ResponsesChannel
from azure.identity.aio import DefaultAzureCredential
SESSIONS_DIR = Path(__file__).resolve().parent / "storage" / "sessions"
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
@tool(approval_mode="never_require")
def lookup_weather(
location: Annotated[str, "The city to look up weather for."],
) -> str:
"""Return a deterministic weather report for a city."""
high_temp = randint(5, 25)
reports = {
"Seattle": f"Seattle is rainy with a high of {high_temp}°C.",
"Amsterdam": f"Amsterdam is cloudy with a high of {high_temp}°C.",
"Tokyo": f"Tokyo is clear with a high of {high_temp}°C.",
}
return reports.get(location, f"{location} is sunny with a high of {high_temp}°C.")
def responses_hook(request: ChannelRequest, **_: object) -> ChannelRequest:
"""Strip caller-supplied options the host should own and force a
reasoning preset."""
options = dict(request.options or {})
# The agent's default_options own ``store``; the model may not honor
# ``temperature``. Strip both so the caller can't override.
options.pop("temperature", None)
options.pop("store", None)
# Force a consistent reasoning preset on every turn.
options["reasoning"] = {"effort": "medium", "summary": "auto"}
return replace(request, options=options or None)
def build_host() -> AgentFrameworkHost:
agent = Agent(
client=FoundryChatClient(credential=DefaultAzureCredential()),
name="WeatherAgent",
instructions=(
"You are a friendly weather assistant. Use the lookup_weather tool "
"for any weather question and answer in one short sentence."
),
tools=[lookup_weather],
context_providers=[FileHistoryProvider(SESSIONS_DIR)],
default_options={"store": False},
)
return AgentFrameworkHost(
target=agent,
channels=[ResponsesChannel(run_hook=responses_hook)],
debug=True,
)
app = build_host().app
if __name__ == "__main__":
build_host().serve(host="0.0.0.0", port=int(os.environ.get("PORT", "8000")))
@@ -0,0 +1,47 @@
# Copyright (c) Microsoft. All rights reserved.
"""Local client for the local_responses sample.
Posts to ``/responses`` using the standard ``openai`` SDK.
Pass ``--previous-response-id <id>`` to continue a conversation by its
``response.id`` (returned in the prior response).
Start the server first (in another shell)::
uv run python app.py
Then::
uv run python call_server.py "What is the weather in Tokyo?"
"""
from __future__ import annotations
import sys
from openai import OpenAI
BASE_URL = "http://127.0.0.1:8000"
def main() -> None:
args = sys.argv[1:]
previous_response_id: str | None = None
if len(args) >= 2 and args[0] == "--previous-response-id":
previous_response_id = args[1]
args = args[2:]
print(f"Resuming response: {previous_response_id}")
prompt = " ".join(args) or "What is the weather in Tokyo?"
client = OpenAI(base_url=BASE_URL, api_key="not-needed")
response = client.responses.create(
model="agent",
input=prompt,
previous_response_id=previous_response_id,
)
print(f"User: {prompt}")
print(f"Agent: {response.output_text}")
if __name__ == "__main__":
main()
@@ -0,0 +1,23 @@
[project]
name = "agent-framework-hosting-sample-local-responses"
version = "0.0.1"
description = "Minimal Responses-only local hosting sample with a settings-altering run hook."
requires-python = ">=3.10"
dependencies = [
"agent-framework-foundry",
"agent-framework-hosting",
"agent-framework-hosting-responses",
"azure-identity",
"aiohttp>=3.13.5",
"hypercorn>=0.17",
]
[dependency-groups]
dev = ["openai>=1.99"]
[tool.uv]
package = false
[tool.uv.sources]
agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting" }
agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-responses" }
@@ -0,0 +1,86 @@
# local_responses_workflow — workflow target with structured intake + checkpoints
A `Workflow` (intake → writer → legal reviewer → formatter) hosted
behind **both the Responses API and the Invocations API**, with the
host configured to **persist per-conversation checkpoints**. Mirrors
[`../../foundry-hosted-agents/responses/04_workflows/`](../../foundry-hosted-agents/responses/04_workflows/)
but uses the `agent-framework-hosting` stack instead of the
Foundry-Hosted-Agents runtime, and adds a structured intake step
(`SloganBrief` with `topic` / `style` / `audience` fields) at the front
of the workflow.
## What's interesting
- `AgentFrameworkHost(target=workflow, …)` — the host detects a
`Workflow` target and dispatches to `workflow.run(...)` (no
`Agent.create_session(...)`).
- Two channels are mounted side-by-side (`ResponsesChannel` at
`/responses`, `InvocationsChannel` at `/invocations/invoke`). Both
share the **same `brief_hook`** that **adapts the channel-native
input into the workflow start executor's typed input** — Responses
delivers a `list[Message]`, Invocations delivers a `str`, but the
hook normalises both to text and produces a `SloganBrief`.
- The hook parses the inbound text as JSON
(`{"topic": ..., "style": ..., "audience": ...}`); if parsing fails
it uses the whole text as `topic` with defaults.
- The workflow's first executor (`BriefIntakeExecutor`) accepts
`SloganBrief` directly — that's what gets sent into `workflow.run(...)`
by the host.
- `checkpoint_location=storage/checkpoints/` — the host scopes a
`FileCheckpointStorage` per conversation (Responses keys it on
`previous_response_id` / `conversation_id`; Invocations keys it on
`session_id`) and **restores from the latest checkpoint at the start
of every turn** before applying the new input. Without an isolation
key the host skips checkpointing for that request.
- No `HistoryProvider` — the workflow owns its own state via the
checkpoint store.
## Run
```bash
export FOUNDRY_PROJECT_ENDPOINT=https://<your-project>.services.ai.azure.com
export FOUNDRY_MODEL=gpt-5.4-nano
az login
uv sync
uv run hypercorn app:app --bind 0.0.0.0:8000
```
Single-process for quick iteration:
```bash
uv run python app.py
```
## Call locally
Two clients are provided next to `app.py`:
- **`call_server.py`** — Python client using the OpenAI SDK (Responses
API only).
- **`call_server.rest`** — raw REST examples for **both** the Responses
and Invocations endpoints (open in VS Code with the REST Client
extension or any compatible HTTP-file runner).
```bash
uv sync --group dev
# Structured brief via the OpenAI SDK (Responses API):
uv run python call_server.py \
'{"topic": "electric SUV", "style": "playful", "audience": "young families"}'
# Plain topic (style/audience default to "modern" / "general"):
uv run python call_server.py "electric SUV"
# Continue an existing conversation by its `response.id`:
uv run python call_server.py --previous-response-id <response-id> \
'{"topic": "electric SUV", "style": "retro", "audience": "boomers"}'
```
After a few turns, inspect `storage/checkpoints/<isolation_key>/`
each conversation has its own subdirectory of checkpoint files written
by the host.
> This sample is **local-only** — no Dockerfile, no Foundry packaging.
> For a Foundry-Hosted-Agents-compatible packaging see
> [`../foundry_hosted_agent`](../foundry_hosted_agent).
@@ -0,0 +1,225 @@
# Copyright (c) Microsoft. All rights reserved.
"""Hosted workflow sample with a structured intake step + checkpoint location.
Same three-agent slogan workflow as
``../../foundry-hosted-agents/responses/04_workflows/main.py`` (writer →
legal reviewer → formatter), but with an extra **structured intake**
step at the front and driven through the ``agent-framework-hosting``
stack instead of the Foundry-Hosted-Agents runtime.
Workflow shape
--------------
``BriefIntakeExecutor`` (typed :class:`SloganBrief` input) → ``writer``
→ ``legal_reviewer`` → ``formatter``. The intake step formats the
structured brief into a prompt the writer agent understands.
What this sample shows
----------------------
- A :class:`~agent_framework.Workflow` is a valid hosting target — the
host detects it and dispatches to ``workflow.run(...)`` instead of
``agent.run(...)``.
- ``ResponsesChannel(run_hook=...)`` (and the same hook on
``InvocationsChannel``) is the seam for **adapting the channel-native
input into the workflow start executor's typed input**. The hook here
parses the inbound text as JSON
(``{"topic": ..., "style": ..., "audience": ...}``) — if parsing
fails it falls back to using the whole text as ``topic`` with
defaults — and replaces ``ChannelRequest.input`` with a
:class:`SloganBrief`.
- ``AgentFrameworkHost(checkpoint_location=...)`` enables
per-conversation workflow checkpointing. The host scopes the
checkpoint storage by ``ChannelRequest.session.isolation_key``
(Responses uses ``previous_response_id`` / ``conversation_id`` as the
isolation key), and restores from the latest checkpoint before each
new turn — so a multi-turn workflow can resume across requests.
- No ``HistoryProvider`` is configured: the workflow owns its own state
via the checkpoint store; the agent-history seam is for plain
``SupportsAgentRun`` agents.
Run
---
``app`` is a module-level Starlette ASGI app::
uv sync
az login
export FOUNDRY_PROJECT_ENDPOINT=https://<your-project>.services.ai.azure.com
export FOUNDRY_MODEL=gpt-5.4-nano
uv run hypercorn app:app --bind 0.0.0.0:8000
Or for quick iteration::
uv run python app.py
Then call it with a structured brief::
uv run python call_server.py \\
'{"topic": "electric SUV", "style": "playful", "audience": "young families"}'
Or with just a topic — the hook fills in defaults::
uv run python call_server.py "Create a slogan for an electric SUV."
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, replace
from pathlib import Path
from agent_framework import (
Agent,
AgentExecutor,
Executor,
Message,
WorkflowBuilder,
WorkflowContext,
handler,
)
from agent_framework_foundry import FoundryChatClient
from agent_framework_hosting import AgentFrameworkHost, ChannelRequest
from agent_framework_hosting_invocations import InvocationsChannel
from agent_framework_hosting_responses import ResponsesChannel
from azure.identity.aio import DefaultAzureCredential
CHECKPOINTS_DIR = Path(__file__).resolve().parent / "storage" / "checkpoints"
CHECKPOINTS_DIR.mkdir(parents=True, exist_ok=True)
@dataclass
class SloganBrief:
"""Typed input for the workflow's first executor."""
topic: str
style: str = "modern"
audience: str = "general"
class BriefIntakeExecutor(Executor):
"""Format a :class:`SloganBrief` into a prompt for the writer agent."""
@handler
async def handle(self, brief: SloganBrief, ctx: WorkflowContext[str]) -> None:
prompt = (
f"Topic: {brief.topic}\n"
f"Style: {brief.style}\n"
f"Audience: {brief.audience}\n\n"
"Write a single short slogan that fits the topic, style, and audience."
)
await ctx.send_message(prompt)
def _extract_text(value: object) -> str:
"""Pull plain text out of whatever the Responses channel produced.
The channel hands the host either a ``str`` (rare on the Responses
surface) or a list of :class:`Message`. The hook collapses both to
a single concatenated string before attempting to parse a brief.
"""
if isinstance(value, str):
return value
if isinstance(value, Message):
return value.text
if isinstance(value, list):
return "\n".join(_extract_text(item) for item in value)
return ""
def _parse_brief(text: str) -> SloganBrief:
"""Parse user text into a :class:`SloganBrief`.
Accepts a JSON object with ``topic`` / ``style`` / ``audience``
keys; falls back to using the whole text as ``topic`` with the
other fields defaulted.
"""
text = text.strip()
if text.startswith("{"):
try:
data = json.loads(text)
except json.JSONDecodeError:
data = None
if isinstance(data, dict) and "topic" in data:
return SloganBrief(
topic=str(data["topic"]),
style=str(data.get("style", "modern")),
audience=str(data.get("audience", "general")),
)
return SloganBrief(topic=text or "a generic product")
def brief_hook(request: ChannelRequest, **_: object) -> ChannelRequest:
"""Adapt the channel's free-form text into the workflow's typed input.
This is the canonical seam for shaping ``ChannelRequest.input`` into
the workflow start executor's input type — here :class:`SloganBrief`
instead of ``str`` / ``list[Message]``. Shared between the Responses
channel (which delivers a list of :class:`Message`) and the
Invocations channel (which delivers a plain ``str``).
"""
brief = _parse_brief(_extract_text(request.input))
return replace(request, input=brief)
def build_host() -> AgentFrameworkHost:
client = FoundryChatClient(credential=DefaultAzureCredential())
writer = Agent(
client=client,
name="writer",
instructions=("You are an excellent slogan writer. You create new slogans based on the given topic."),
)
legal = Agent(
client=client,
name="legal_reviewer",
instructions=(
"You are an excellent legal reviewer. "
"Make necessary corrections to the slogan so that it is legally compliant."
),
)
formatter = Agent(
client=client,
name="formatter",
instructions=(
"You are an excellent content formatter. "
"You take the slogan and format it in a cool retro style when printing to a terminal."
),
)
intake_ex = BriefIntakeExecutor(id="intake")
# ``context_mode="last_agent"`` ensures each agent only sees the
# previous executor's output — matching the Foundry sample.
writer_ex = AgentExecutor(writer, context_mode="last_agent")
legal_ex = AgentExecutor(legal, context_mode="last_agent")
format_ex = AgentExecutor(formatter, context_mode="last_agent")
workflow = (
WorkflowBuilder(
start_executor=intake_ex,
output_executors=[format_ex],
)
.add_edge(intake_ex, writer_ex)
.add_edge(writer_ex, legal_ex)
.add_edge(legal_ex, format_ex)
.build()
)
return AgentFrameworkHost(
target=workflow,
channels=[
ResponsesChannel(run_hook=brief_hook),
InvocationsChannel(run_hook=brief_hook),
],
# The host writes a per-conversation FileCheckpointStorage rooted
# at ``CHECKPOINTS_DIR / <isolation_key>`` and restores from the
# latest checkpoint at the start of every turn.
checkpoint_location=CHECKPOINTS_DIR,
debug=True,
)
app = build_host().app
if __name__ == "__main__":
build_host().serve(host="0.0.0.0", port=int(os.environ.get("PORT", "8000")))
@@ -0,0 +1,54 @@
# Copyright (c) Microsoft. All rights reserved.
"""Local client for the local_responses_workflow sample.
The server expects a structured slogan brief. You can either pass a
JSON object or a plain topic string (the server's run hook fills the
other fields with defaults).
Pass ``--previous-response-id <id>`` to continue a conversation by its
``response.id`` — the host uses that as the workflow checkpoint scope
key, so the workflow resumes from where it left off.
Start the server first (in another shell)::
uv run python app.py
Then::
uv run python call_server.py \\
'{"topic": "electric SUV", "style": "playful", "audience": "young families"}'
uv run python call_server.py "electric SUV" # uses default style/audience
"""
from __future__ import annotations
import sys
from openai import OpenAI
BASE_URL = "http://127.0.0.1:8000"
def main() -> None:
args = sys.argv[1:]
previous_response_id: str | None = None
if len(args) >= 2 and args[0] == "--previous-response-id":
previous_response_id = args[1]
args = args[2:]
print(f"Resuming response: {previous_response_id}")
prompt = " ".join(args) or '{"topic": "electric SUV", "style": "playful", "audience": "young families"}'
client = OpenAI(base_url=BASE_URL, api_key="not-needed")
response = client.responses.create(
model="agent",
input=prompt,
previous_response_id=previous_response_id,
)
print(f"User: {prompt}")
print(f"Agent: {response.output_text}")
print(f"response.id: {response.id}")
if __name__ == "__main__":
main()
@@ -0,0 +1,92 @@
# local_responses_workflow — REST examples
#
# Use with the VS Code "REST Client" extension (humao.rest-client) or
# JetBrains HTTP Client. Each `###` block is one request.
#
# Start the server in another shell first:
# uv run python app.py
@host = http://127.0.0.1:8000
###
# 1. Responses API — structured brief
POST {{host}}/responses
Content-Type: application/json
{
"model": "agent",
"input": "{\"topic\": \"electric SUV\", \"style\": \"playful\", \"audience\": \"young families\"}"
}
###
# 2. Responses API — plain topic, defaults applied by the run hook
POST {{host}}/responses
Content-Type: application/json
{
"model": "agent",
"input": "vintage espresso machine"
}
###
# 3. Responses API — continue the conversation by previous_response_id
# Replace <RESPONSE_ID> with `id` from one of the responses above —
# the host uses it as the workflow checkpoint scope key, so the
# workflow resumes from its latest checkpoint before applying the
# new input.
POST {{host}}/responses
Content-Type: application/json
{
"model": "agent",
"previous_response_id": "<RESPONSE_ID>",
"input": "{\"topic\": \"electric SUV\", \"style\": \"retro\", \"audience\": \"boomers\"}"
}
###
# 4. Invocations API — structured brief
POST {{host}}/invocations/invoke
Content-Type: application/json
{
"message": "{\"topic\": \"electric SUV\", \"style\": \"playful\", \"audience\": \"young families\"}",
"session_id": "demo-1"
}
###
# 5. Invocations API — plain topic
POST {{host}}/invocations/invoke
Content-Type: application/json
{
"message": "noise-cancelling headphones",
"session_id": "demo-2"
}
###
# 6. Invocations API — resume the same session_id to reuse the
# workflow's per-conversation checkpoint store.
POST {{host}}/invocations/invoke
Content-Type: application/json
{
"message": "{\"topic\": \"noise-cancelling headphones\", \"style\": \"minimalist\", \"audience\": \"developers\"}",
"session_id": "demo-2"
}
###
# 7. Invocations API — streaming (SSE; one `data:` line per chunk,
# terminated by `data: [DONE]`).
POST {{host}}/invocations/invoke
Content-Type: application/json
Accept: text/event-stream
{
"message": "{\"topic\": \"reusable water bottle\", \"style\": \"bold\", \"audience\": \"college students\"}",
"session_id": "demo-3",
"stream": true
}
###
# 8. Readiness probe
GET {{host}}/readiness
@@ -0,0 +1,25 @@
[project]
name = "agent-framework-hosting-sample-local-responses-workflow"
version = "0.0.1"
description = "Local hosting sample exposing a 3-agent workflow over the Responses API with per-conversation checkpoint storage."
requires-python = ">=3.10"
dependencies = [
"agent-framework-foundry",
"agent-framework-hosting",
"agent-framework-hosting-invocations",
"agent-framework-hosting-responses",
"azure-identity",
"aiohttp>=3.13.5",
"hypercorn>=0.17",
]
[dependency-groups]
dev = ["openai>=1.99"]
[tool.uv]
package = false
[tool.uv.sources]
agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting" }
agent-framework-hosting-invocations = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-invocations" }
agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-responses" }
@@ -0,0 +1,59 @@
# local_telegram — `@tool`, file-backed history, hooks, multicast
Builds on `foundry_hosted_agent/` with the hooks and config most real apps need:
- A `@tool`-decorated function call (`get_weather`) so streaming and tool
invocation are exercised end-to-end.
- `FileHistoryProvider(./storage/sessions)` so per-user/per-chat history
survives restarts.
- A `responses_hook` that keys each session off the OpenAI
`safety_identifier` field, so multiple users on the Responses endpoint
do not share history.
- A `telegram_hook` that keys per-chat sessions via `telegram_isolation_key`.
- Two extra Telegram commands (`/new`, `/whoami`).
- `ResponseTarget` multicast: a Responses request can fan out the agent
reply to a Telegram chat by passing
`extra_body={"response_target": ["originating", "telegram:<chat_id>"]}`.
`app:app` is a module-level Starlette ASGI app, so this sample runs under
Hypercorn (multi-process).
## Run
```bash
export FOUNDRY_PROJECT_ENDPOINT=https://<your-project>.services.ai.azure.com
export FOUNDRY_MODEL=gpt-4o
export TELEGRAM_BOT_TOKEN=...
az login
uv sync
uv run hypercorn app:app \
--bind 0.0.0.0:8000 \
--workers 4
```
Single-process for quick iteration:
```bash
uv run python app.py
```
## Call locally
```bash
uv sync --group dev
# Plain call:
uv run python call_server.py "What is the weather in Tokyo?"
# Resume an existing session by AgentSession id (works across channels):
uv run python call_server.py --previous-response-id telegram:8741188429 "What did we discuss?"
# Multicast: keep the reply on the local wire AND push it to Telegram.
uv run python call_server_multicast.py --telegram-chat-id 8741188429 "Heads up."
```
> This sample is **local-only** — it shows the `agent-framework-hosting`
> server stack as a standalone process. For a Foundry-Hosted-Agents-compatible
> packaging (Dockerfile + `agent.yaml` + `azure.yaml`), see
> [`foundry_hosted_agent/`](../foundry_hosted_agent).
@@ -0,0 +1,227 @@
# Copyright (c) Microsoft. All rights reserved.
"""Advanced multi-channel hosting sample.
Builds on ``app.py`` to demonstrate:
- a function ``@tool`` on the agent (``lookup_weather``),
- per-isolation-key history persisted via ``FileHistoryProvider``,
- a ``ResponsesChannel`` ``run_hook`` that clamps caller-supplied
``ChatOptions`` and honours the OpenAI ``previous_response_id`` field as
the ``AgentSession`` id — so a Responses caller can resume a Telegram
chat by passing ``previous_response_id="telegram:<chat_id>"`` (or any
other isolation key written by another channel),
- a ``TelegramChannel`` ``run_hook`` that bumps ``temperature`` for a
chattier Telegram persona,
- a richer Telegram command catalog including a ``/new`` command that resets
the cached session for the chat.
Required env: ``FOUNDRY_PROJECT_ENDPOINT``, ``FOUNDRY_MODEL``,
``TELEGRAM_BOT_TOKEN``. Auth uses ``DefaultAzureCredential``.
Run
---
This module exposes ``app`` as the canonical ASGI surface. Recommended
production launch is **Hypercorn**::
hypercorn app:app --bind 0.0.0.0:8000 --workers 4
The ``__main__`` block below uses ``host.serve(...)`` (single-process
Hypercorn) as a local-dev fallback.
Note
----
``FileHistoryProvider`` provides only in-process file-write locking. Running
multiple Hypercorn workers against the same ``./sessions`` directory is fine
for this sample, but a production deployment should swap it for a store with
cross-process consistency.
"""
from __future__ import annotations
import os
from dataclasses import replace
from pathlib import Path
from random import randint
from typing import Annotated
from agent_framework import Agent, FileHistoryProvider, tool
from agent_framework_foundry import FoundryChatClient
from agent_framework_hosting import (
AgentFrameworkHost,
ChannelCommand,
ChannelCommandContext,
ChannelRequest,
ChannelSession,
)
from agent_framework_hosting_responses import ResponsesChannel
from agent_framework_hosting_telegram import TelegramChannel, telegram_isolation_key
from azure.identity.aio import DefaultAzureCredential
# import logging
# logging.basicConfig(level=logging.DEBUG)
SESSIONS_DIR = Path(__file__).resolve().parent / "storage" / "sessions"
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
# --------------------------------------------------------------------------- #
# Tools the agent can call
# --------------------------------------------------------------------------- #
@tool(approval_mode="never_require")
def lookup_weather(
location: Annotated[str, "The city to look up weather for."],
) -> str:
"""Return a deterministic weather report for a city."""
high_temp = randint(5, 25)
reports = {
"Seattle": f"Seattle is rainy with a high of {high_temp}°C.",
"Amsterdam": f"Amsterdam is cloudy with a high of {high_temp}°C.",
"Tokyo": f"Tokyo is clear with a high of {high_temp}°C.",
}
return reports.get(location, f"{location} is sunny with a high of {high_temp}°C.")
# --------------------------------------------------------------------------- #
# Responses channel run hook
# --------------------------------------------------------------------------- #
def responses_hook(request: ChannelRequest, *, protocol_request: dict | None = None, **_: object) -> ChannelRequest:
"""Validate, rewrite, and key the channel-built ChannelRequest before invocation.
The spec calls this out as the developer's runtime escape hatch over the
uniform ``ChannelRequest`` envelope. Things this hook does:
- **strip** ``store`` and ``temperature`` (the agent owns persistence via ``FileHistoryProvider``),
- **inject a session** keyed on the request body. The OpenAI Responses
``previous_response_id`` field doubles as our isolation key — the
``ResponsesChannel`` already lifts it onto ``request.session``, so any
caller can resume an arbitrary AgentSession (including one written by
another channel, e.g. ``telegram:8741188429``) by passing it as
``previous_response_id``. When the caller doesn't pass one, fall back
to a key derived from the OpenAI ``safety_identifier`` field
(``responses:<id>``).
"""
options = dict(request.options or {})
# this agent will only run with models that do not support Temperature, so removing it.
options.pop("temperature", None)
options.pop("store", None)
body = protocol_request or {}
if request.session is not None and request.session.isolation_key:
# Caller supplied ``previous_response_id`` — the channel already
# used it as the AgentSession id. Keep it as-is.
session = request.session
else:
safety_id = body.get("safety_identifier") or "anonymous"
session = ChannelSession(isolation_key=f"responses:{safety_id}")
return replace(
request,
session=session,
options=options or None,
)
def telegram_hook(request: ChannelRequest, **_: object) -> ChannelRequest:
"""Telegram users get a chattier model — bump temperature on every turn."""
options = dict(request.options or {})
options["reasoning"] = {"effort": "high", "summary": "detailed"}
return replace(request, options=options)
# --------------------------------------------------------------------------- #
# Telegram commands
# --------------------------------------------------------------------------- #
def _isolation_key(ctx: ChannelCommandContext) -> str:
return telegram_isolation_key(ctx.request.attributes.get("chat_id"))
def make_commands(host_ref: dict[str, AgentFrameworkHost]) -> list[ChannelCommand]:
"""Build commands that close over the host so ``/new`` can reset state."""
async def handle_start(ctx: ChannelCommandContext) -> None:
await ctx.reply("Hi! I'm a multi-channel agent.\nCommands: /new, /whoami, /weather <city>, /help.")
async def handle_help(ctx: ChannelCommandContext) -> None:
await ctx.reply(
"/new — start a fresh conversation\n"
"/whoami — show your isolation key\n"
"/weather <city> — call the weather tool directly\n"
"/help — this message"
)
async def handle_new(ctx: ChannelCommandContext) -> None:
host_ref["host"].reset_session(_isolation_key(ctx))
await ctx.reply("New session started. Previous history is cleared for this chat.")
async def handle_whoami(ctx: ChannelCommandContext) -> None:
await ctx.reply(f"Your isolation key on this host is: {_isolation_key(ctx)}")
async def handle_weather(ctx: ChannelCommandContext) -> None:
# Bypass the agent and call the tool directly to demonstrate that
# commands have full control over how they reply.
command_text = ctx.request.input if isinstance(ctx.request.input, str) else ""
_, _, location = command_text.partition(" ")
location = location.strip() or "Seattle"
await ctx.reply(lookup_weather(location=location))
return [
ChannelCommand("start", "Introduce the bot", handle_start),
ChannelCommand("help", "List available commands", handle_help),
ChannelCommand("new", "Start a new session for this chat", handle_new),
ChannelCommand("whoami", "Show the isolation key for this chat", handle_whoami),
ChannelCommand("weather", "Call the weather tool: /weather <city>", handle_weather),
]
# --------------------------------------------------------------------------- #
# Host wiring
# --------------------------------------------------------------------------- #
def build_host() -> AgentFrameworkHost:
agent = Agent(
client=FoundryChatClient(credential=DefaultAzureCredential()),
name="WeatherAgent",
instructions=(
"You are a friendly weather assistant. Use the lookup_weather tool "
"for any weather question and answer in one short sentence."
),
tools=[lookup_weather],
context_providers=[FileHistoryProvider(SESSIONS_DIR)],
default_options={"store": False},
)
host_ref: dict[str, AgentFrameworkHost] = {}
host = AgentFrameworkHost(
target=agent,
channels=[
ResponsesChannel(run_hook=responses_hook),
TelegramChannel(
bot_token=os.environ["TELEGRAM_BOT_TOKEN"],
webhook_url=os.environ.get("TELEGRAM_WEBHOOK_URL"),
secret_token=os.environ.get("TELEGRAM_WEBHOOK_SECRET"),
parse_mode="Markdown",
commands=make_commands(host_ref),
run_hook=telegram_hook,
),
],
debug=True,
)
host_ref["host"] = host
return host
app = build_host().app
if __name__ == "__main__":
build_host().serve(host="0.0.0.0", port=int(os.environ.get("PORT", "8000")))
@@ -0,0 +1,54 @@
# Copyright (c) Microsoft. All rights reserved.
"""Local client for the advanced agent — POSTs to the ``/responses`` endpoint
exposed by ``server/advanced_app.py`` using the standard ``openai`` SDK.
The advanced server's ``responses_hook`` keys per-user history off the
OpenAI ``safety_identifier`` field, so we pass ``safety_identifier=`` here.
Pass ``--previous-response-id <id>`` to resume an existing AgentSession by
its isolation key. Because the server uses ``previous_response_id`` directly
as the ``AgentSession`` id, you can resume any session written by any
channel — for example a Telegram chat at
``--previous-response-id telegram:8741188429``.
Start the server first (in another shell)::
cd server && uv run python advanced_app.py
Then::
python call_server.py "What is the weather in Tokyo?"
python call_server.py --previous-response-id telegram:8741188429 "What did we discuss?"
"""
from __future__ import annotations
import sys
from openai import OpenAI
BASE_URL = "http://127.0.0.1:8000"
def main() -> None:
args = sys.argv[1:]
previous_response_id: str | None = None
if len(args) >= 2 and args[0] == "--previous-response-id":
previous_response_id = args[1]
args = args[2:]
print(f"Resuming AgentSession: {previous_response_id}")
prompt = " ".join(args) or "What is the weather in Seattle?"
client = OpenAI(base_url=BASE_URL, api_key="not-needed")
response = client.responses.create(
model="agent",
input=prompt,
safety_identifier="local-dev",
previous_response_id=previous_response_id,
)
print(f"User: {prompt}")
print(f"Agent: {response.output_text}")
if __name__ == "__main__":
main()
@@ -0,0 +1,92 @@
# Copyright (c) Microsoft. All rights reserved.
"""Local client demonstrating server-side ``ResponseTarget`` fan-out.
Posts one request to ``/responses`` with
``extra_body={"response_target": ["originating", "telegram:<chat_id>"]}``.
The server invokes the agent once and the host's
``ChannelContext.deliver_response`` resolves the target list against the
configured channels, calling :class:`host.ChannelPush` ``push`` on each
non-originating destination — here, the operator's Telegram chat. The
``"originating"`` pseudo-name keeps the agent reply on this script's wire
too, so the local terminal sees the reply alongside Telegram.
Drop ``--include-originating`` to deliver only to Telegram (the local
response becomes a small acknowledgement string referencing the push
targets).
The ``--previous-response-id`` flag (the AgentSession id) is independent
of ``--telegram-chat-id`` (the push destination). They were conflated in
an earlier iteration; in general one Entra user may have several Telegram
chat ids, and the session id is usually their Entra/responses isolation
key, not the chat id. Pass them both to resume a specific session and
fan-out to a specific chat::
python call_server_multicast.py \\
--previous-response-id telegram:8741188429 \\
--telegram-chat-id 8741188429 \\
"What did we discuss?"
Start the server first (in another shell)::
cd server && uv run python advanced_app.py
"""
from __future__ import annotations
import argparse
from openai import OpenAI
BASE_URL = "http://127.0.0.1:8000"
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
"--telegram-chat-id",
required=True,
help="Native Telegram chat id to push the agent reply to.",
)
parser.add_argument(
"--previous-response-id",
default=None,
help=(
"Existing AgentSession id (e.g. 'telegram:8741188429' or "
"'responses:local-dev'). Defaults to no resume — the server "
"creates a fresh session keyed by safety_identifier."
),
)
parser.add_argument(
"--no-originating",
action="store_true",
help="Skip 'originating' in response_target; only Telegram receives the reply.",
)
parser.add_argument("prompt", nargs="*", help="Prompt to send to the agent.")
args = parser.parse_args()
prompt = " ".join(args.prompt) or "What is the weather in Seattle?"
response_target: list[str] = []
if not args.no_originating:
response_target.append("originating")
response_target.append(f"telegram:{args.telegram_chat_id}")
if args.previous_response_id:
print(f"Resuming AgentSession: {args.previous_response_id}")
print(f"response_target: {response_target}")
client = OpenAI(base_url=BASE_URL, api_key="not-needed")
response = client.responses.create(
model="agent",
input=prompt,
safety_identifier="local-dev",
previous_response_id=args.previous_response_id,
extra_body={"response_target": response_target},
)
print(f"User: {prompt}")
print(f"Agent: {response.output_text}")
if __name__ == "__main__":
main()
@@ -0,0 +1,26 @@
[project]
name = "agent-framework-hosting-sample-advanced"
version = "0.0.1"
description = "Advanced multi-channel hosting sample (Responses + Telegram with @tool, FileHistoryProvider, hooks, ResponseTarget multicast)."
requires-python = ">=3.10"
dependencies = [
"agent-framework-foundry",
"agent-framework-hosting",
"agent-framework-hosting-responses",
"agent-framework-hosting-telegram",
"azure-identity",
"aiohttp>=3.13.5",
"hypercorn>=0.17",
"httpx>=0.27",
]
[dependency-groups]
dev = ["openai>=1.99"]
[tool.uv]
package = false
[tool.uv.sources]
agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting" }
agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-responses" }
agent-framework-hosting-telegram = { git = "https://github.com/microsoft/agent-framework.git", branch = "feature/python-hosting", subdirectory = "python/packages/hosting-telegram" }