A distributed ski resort system built with Microsoft Agent Framework (MAF), Azure AI Foundry, A2A, Agent Skills over MCP, Voice Live, and Aspire.
An AI-powered ski resort concierge that coordinates weather intelligence, lift traffic, safety evaluation, personalized coaching, web-backed ski research, and voice conversations. Compare remote specialist agents with remote skill instructions and typed MCP tools on the same dashboard.
Compatibility: the skills path uses a pinned historical draft profile of SEP-2640, not the latest discovery contract. As of September 10, 2026, the upstream text is marked Accepted while its PR remains unmerged. Core MCP resources/tools and MAF's experimental progressive-loading API are distinct from that evolving profile. See the profile details.
| Component | Language | Role |
|---|---|---|
skiadvisora2a |
.NET | Existing Foundry-hosted orchestrator using specialists as A2A tools |
skiadvisorskill |
Python | Native MAF skills and MCP tools advisor; both Responses and A2A hosts use Cosmos-backed history |
voiceadvisora2a |
.NET | Voice Live bridge exposing the four specialist A2A agents plus the ski researcher |
voiceadvisorskill |
.NET | Voice Live bridge exposing only the skiadvisorskilla2a orchestrator |
| Compact A2A resources | Python/.NET | weatheragenta2a, safetyagenta2a, skicoachagenta2a, and lifttrafficagenta2a |
| Compact skill resources | .NET | weatherskills, safetyskills, skicoachskills, and lifttrafficskills, exposing instructional skills and twelve typed MCP tools |
| Ski Researcher | Foundry | Existing web-backed prompt agent used directly as a tool by both orchestrators |
| Data Generator | Go | Continuously generates synthetic resort telemetry |
| Frontend | React/Vite | Real-time dashboard with AI chat and voice controls |
| Desktop | MAUI/Avalonia | Native Linux/macOS dashboard, streaming chat, and Voice Live |
- .NET 10 SDK
- Python 3.11+
- uv (Python package manager)
- Go 1.23+
- Node.js 20+
- Aspire CLI
- An Azure AI Foundry resource with a
gpt-4.1(or similar) deployment - Azure CLI authenticated (
az login)
Refer to the official Aspire documentation for installation instructions.
The repository pins Aspire 17.0.0-preview.1.26530.1 from the daily
channel in aspire.config.json and apphost.cs. The CLI uses that
repository-local version; nuget.config maps Aspire packages to the official
daily feed. Aspire.Azure.AI.Inference uses 17.0.0-preview.1.26514.11,
the latest available daily build of that integration.
To update the repository pins to a newer daily build, run from the repository root:
aspire update --apphost apphost.cs --channel daily --yes --non-interactive --nuget-config-dir .git clone https://github.com/tommasodotNET/ski-resort-demo.git
cd ski-resort-demoCreate or edit apphost.settings.Development.json in the repository root with your Azure details:
{
"Azure": {
"TenantId": "<your-tenant-id>",
"SubscriptionId": "<your-subscription-id>",
"AllowResourceGroupCreation": true,
"ResourceGroup": "<your-resource-group>",
"Location": "<your-azure-region>",
"CredentialSource": "AzureCli"
}
}Note: The Azure AI Foundry resource must have a chat completion model deployed (e.g.,
gpt-4.1). The deployment name is configured in the Aspire AppHost.
In Development, the .NET lift-traffic agent skips managed-identity authentication
so local requests do not wait on Azure's metadata endpoint. Authenticate with
az login; managed identity remains available outside Development.
From the repository root:
aspire startThis starts both orchestrators, all A2A specialists, their paired .NET MCP skill providers, the shared researcher tool, both voice bridges, frontend, data generator, Foundry resources, and Cosmos DB emulator.
Open the Aspire dashboard (URL shown in terminal output) to see all services, logs, and distributed traces.
The frontend will be available at the URL assigned by Aspire (shown in the dashboard).
The native desktop uses the same MAUI-on-Avalonia approach as puff-schedule
and budgeting-app: MAUI controls rendered by Avalonia, not an embedded browser.
It includes weather measurements and chart, avalanche risk and incidents, grouped
slopes/lifts with status and queues, both advisor architectures, streaming chat,
new conversations, and microphone/speaker voice conversations with live transcripts.
The existing React frontend remains unchanged.
Install the desktop-only SDK and packages once:
bash src/desktop/setup.shThe pinned .NET 11 preview SDK lives in ignored src/desktop/.dotnet/.
Its global.json applies only inside src/desktop; the AppHost and existing
.NET 10 services keep their current SDK. No MAUI mobile workloads are needed.
Delete that specific .dotnet directory to remove the local SDK.
Start Aspire as usual, then start desktop in the Aspire dashboard. The resource is explicit-start so headless machines and existing web-only development do not launch a GUI or require the desktop SDK. It waits for the frontend and receives its dynamic URL automatically. It is not included in Azure publishing.
Aspire injects ALPINEAI_SERVER_URL; the desktop connects automatically and has
no backend address bar or manual connection settings. Starting without that
configuration shows an explicit error directing you to launch through Aspire.
HTTPS is required except for localhost development. The desktop uses the existing
/api, /responses/{a2a|skill}, /ws/voice/{a2a|skill}, and /config.json routes,
including the configured polling interval and hosted skills-session continuation.
Backend services must still be running; the desktop is not an offline backend.
Voice requires default microphone/output devices and PortAudio:
sudo dnf install portaudio (Fedora), sudo apt install libportaudio2
(Ubuntu), or brew install portaudio (macOS). Linux also needs a graphical
session and Avalonia's usual X11/font/native graphics libraries. Voice errors,
including missing devices or denied microphone access, are shown in the app;
the rest of the dashboard works without PortAudio. Use headphones to prevent
speaker audio feeding back into the microphone (native capture does not apply
the browser's echo cancellation).
Create a self-contained desktop build:
bash src/desktop/publish.sh linux-x64 # also linux-arm64, osx-arm64, osx-x64Linux output is under src/desktop/artifacts/<rid>/. Published executables also
require ALPINEAI_SERVER_URL to be supplied by their orchestrating environment.
macOS output is an AlpineAI.app bundle with its microphone permission
description. Build/sign it on macOS and grant microphone access when prompted.
The script ad-hoc signs on macOS; public distribution still requires Developer ID
signing/notarization. PortAudio remains an OS prerequisite. For terminal-based
macOS development, grant microphone access to the terminal hosting the app.
Desktop protocol and headless native UI tests:
cd src/desktop
.dotnet/dotnet test AlpineAI.TestsThe native UI tests cover compact dashboard layouts at 640, 768, 1024, and 1380 pixels and repeated A2A/MCP switches, including long streamed and HTTP errors. They check that old chat bubbles are released and retained managed memory stays bounded. Headless checks do not replace testing macOS rendering or audio hardware.
The AppHost deploys supporting services to Azure Container Apps and the two Responses advisors to Foundry hosted agents:
Azure__SubscriptionId="$AZURE_SUBSCRIPTION_ID" \
Azure__Location="$AZURE_LOCATION" \
Azure__ResourceGroup="$AZURE_RESOURCE_GROUP" \
aspire deploy --apphost apphost.cs --non-interactiveFor this non-production demo, Cosmos DB allows public network access so the
Foundry-hosted skills advisor can reach it without a private endpoint. The AppHost
applies the approved SecurityControl=Ignore tag to the Cosmos account to exempt
it from time-based resource policies. Authentication remains Microsoft Entra ID;
account-key authentication is disabled. Production deployments need their own
approved network and policy configuration.
With the pinned Foundry hosting preview, Aspire's Cosmos role assignment targets
the generated app identity, not the Foundry hosted agent's runtime identity.
After the first deployment, get instance_identity.principal_id from the
skiadvisorskill-ha Foundry agent and grant it the native Cosmos data role:
az cosmosdb sql role assignment create \
--resource-group "$AZURE_RESOURCE_GROUP" \
--account-name "$COSMOS_ACCOUNT_NAME" \
--principal-id "$FOUNDRY_SKILLS_AGENT_PRINCIPAL_ID" \
--role-definition-id 00000000-0000-0000-0000-000000000002 \
--scope /This is the Cosmos DB Built-in Data Contributor role at account scope, matching
Aspire's generated application roles. The native client needs account metadata
during cold startup; a container-only grant was insufficient in hosted testing.
The assignment persists across agent version deployments; repeat it if the Foundry
agent identity is recreated. Missing this binding produces a Cosmos 403 for
Microsoft.DocumentDB/databaseAccounts/readMetadata, even when networking works.
apphost.cs # Aspire orchestration (all services wired here)
aspire.config.json # Aspire CLI version and launch profiles
apphost.settings.Development.json # Local Azure configuration (not committed)
src/
├── ski-advisor-a2a/ # skiadvisora2a orchestrator
├── ski-advisor-skill/ # skiadvisorskill + skiadvisorskilla2a surfaces
├── voice-advisor-agent/ # Shared .NET project for both Voice Live resources
├── a2a/ # Specialist agents exposed over A2A
│ ├── lift-traffic-agent-a2a/ # .NET
│ ├── weather-agent-a2a/ # Python
│ ├── safety-agent-a2a/ # Python
│ └── ski-coach-agent-a2a/ # Python
├── skills/ # .NET MCP skill providers
│ ├── weather-skills/
│ ├── safety-skills/
│ ├── ski-coach-skills/
│ └── lift-traffic-skills/
├── data-generator/ # Go data generator
├── frontend/ # Vite + React + Tailwind dashboard
├── desktop/ # MAUI/Avalonia desktop, clients, tests, local SDK
├── responses-gateway/ # Frontend gateway used when publishing
├── shared-services/ # .NET shared library (Cosmos, thread store)
└── service-defaults/ # Aspire service defaults
apphost.cs is the source of truth for active services, including publish-only
resources. Shared libraries remain alongside the advisors; the ski researcher is
a Foundry prompt agent declared in the AppHost, not a separate local MCP project.
The lift-traffic A2A project contains only its agent and local function tools;
MCP resources and tool hosting live in src/skills/lift-traffic-skills/.
The A2A Agent Card's Skills metadata is part of A2A discovery, not MCP hosting.
The skills advisor composes MAF's SkillsProvider / MCPSkillsSource with
native MCP functions and request-local FunctionInvocationContext.add_tools.
After a skill loads successfully, a small host hook registers every tool from
that skill's configured provider for the next model iteration. The model sees
their descriptions and schemas before choosing which to invoke. This is the
skills architecture's single execution path; no mode switch or Foundry Toolbox
is required.
Both Responses and A2A hosting surfaces use the same builder. The frontend's separate A2A-specialist architecture selection remains unchanged. See the skills advisor README for tool loading, approval, lifetime, and local testing.
The shared builder enables multiple tool calls per model response. Independent skill loads and tool calls execute concurrently through MAF's native invocation loop; tools that depend on loaded instructions or earlier results run afterward.
Both skills-advisor hosts use the existing Cosmos DB skillhistory container.
The frontend sends a stable conversation ID for skills-chat follow-ups, allowing
the Responses host to resume the same agent session and reload earlier turns.
This small demo illustrates a larger-tool-catalog pattern. For genuinely small catalogs, load the configured providers' MCP tools upfront with the standard SDK instead. That simpler composition does not need this demo's skill-to-provider registration hook; it can still load skill instructions on demand.
The data generation speed and drift magnitudes are configurable via src/data-generator/config.json:
{
"update_interval_seconds": { "min": 5, "max": 10 },
"weather": { "temperature_drift": 0.1, "wind_speed_drift": 0.5, ... },
"lifts": { "queue_drift": 3, "status_change_probability": 0.002 },
...
}The dashboard polling interval is configurable via src/frontend/public/config.json:
{
"pollingIntervalMs": 10000
}Changes are picked up automatically without restarting.
-
Data Generator continuously produces synthetic weather, lift, slope, and safety telemetry via a REST API.
-
Weather, lift, safety, and coach each have an A2A resource and a paired .NET MCP Agent Skill resource. Skill instructions and typed tools are published remotely rather than embedding domain operations in the skills orchestrator.
-
Ski Researcher Agent remains an Azure AI Foundry prompt agent with web search. Both orchestrators register it directly as an agent tool, demonstrating that one agent can combine tools and skills.
-
skiadvisora2apreserves the agent-as-tool architecture.skiadvisorskilluses native MAF skill loading and progressive MCP tool registration. Its model calls registered provider tools directly over MCP. The Foundry ski researcher remains a separate direct agent tool for both orchestrators. -
voiceadvisora2aandvoiceadvisorskillrun the same Voice Live bridge with architecture-specific configuration. The A2A resource registers only the four specialist A2A agents plus the ski researcher; the skill resource registers onlyskiadvisorskilla2a. The voice conversation ID is preserved through either route and becomes the remote skills-orchestrator session ID when that tool is used. -
Frontend displays real-time data panels, provides an AI chat panel, and sends voice sessions to
/ws/voice/a2aor/ws/voice/skillaccording to the selected architecture.
This sample implements the same four specialist domains as A2A agents and MCP skills:
| Agent as a tool | Agent as a skill |
|---|---|
| The advisor sees one function per remote A2A agent | The advisor initially sees skill summaries and skill-loading functions |
| Invoking the function starts a second specialist model run | Successful load_skill supplies instructions and triggers registration of the provider's tools |
| The specialist model chooses and calls its own tools | The advisor follows SKILL.md and chooses from the group's full tool descriptions and schemas |
| A2A returns the specialist's synthesized answer | MCP tools/call returns the tool handler's data |
Calling this pattern agent as a skill is an architectural mapping: the skill
does not contain another agent. The Agent Card's name/description become skill
metadata, and the former system prompt becomes an enriched SKILL.md.
Specialist tools stay tools, now exposed by MCP handlers over the existing
remote domain services. Authentication and transport fields do not become
prose. There is no separate specialist model loop, although the advisor uses
multiple model iterations.
Each .NET provider exposes instructional resources plus typed MCP tools:
skill://index.json
skill://weather/SKILL.md
MCP tools/list: weather_current_conditions, weather_forecast, weather_storm_status
The index is the lightweight discovery layer:
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [{
"name": "weather",
"type": "skill-md",
"description": "Weather intelligence for the ski resort",
"url": "skill://weather/SKILL.md"
}]
}SKILL.md contains domain instructions and explains that the provider's tools
become available after the skill loads. Resources carry only
discovery and instructions; operational data is returned by tool handlers
backed by the existing domain services:
builder.Services.AddMcpServer(options =>
options.ServerInfo = new() { Name = "weatherskills", Version = "1.0.0" })
.WithHttpTransport()
.WithResources<WeatherSkillResources>()
.WithTools<WeatherTools>();
app.MapMcp("/skillsmcp");SkillsProvider and MCPSkillsSource discover and load the skill documents.
MCPStreamableHTTPTool discovers authoritative native functions host-side.
A small function-invocation hook associates a successfully loaded skill with its
configured provider and registers that provider's whole catalog using MAF's
experimental FunctionInvocationContext.add_tools API. The functions retain
their descriptions, schemas, native MCP transport, and approval behavior;
there is no custom operation dispatcher.
- The SDK reads discovery metadata and the paginated MCP tool catalogs host-side. The initial model context contains skill summaries and generic skill-loading functions, not domain operation schemas or per-tool loaders.
- The model calls
load_skill({"skill_name":"weather"}); MAF reads the weatherSKILL.md. - After the skill read succeeds, the host registers all three weather tools
for this run through
add_tools. Other providers' tools remain hidden. - On the next model iteration, the model chooses from the weather tools'
descriptions and input schemas, for example
weather_weather_forecast({"hours":6}). - Native MCP
tools/callexecutes the .NET handler, which reads resort data. The same advisor model uses the result to answer.
The model selects the skill; the host determines the tool group. This
skill-to-provider association is application glue, not a feature that
SkillsProvider automatically supplies and not a replacement for authorization.
Each current provider hosts one skill. Loading it exposes every operation in
that provider's catalog, but does not execute them. There are no model-facing
per-provider list/load/unload helpers. See the
advisor documentation
for lifetime and approval details.
The configured providers' tool catalogs are the source of truth; the advisor
does not maintain a duplicate tool-name allowlist. A selected skill exposes its
provider's full catalog, not only name-matched operations. The current trusted
providers expose read-only operations, which retain unattended execution.
Native approval_mode="never_require" and the SDK function-calling loop handle
that execution automatically. Native SkillsProvider settings disable approval
prompts for instruction reads too; there is no approval-resumption bookkeeping.
Registrations belong to the current invocation, not the shared agent. They last through the response stream and disappear when that run ends, including failure or cancellation. Followups start without operation tools and load their skills again; conversation history is separate. MCP connections stay open until app shutdown. The shared native MCP objects do not maintain mutable per-tool-loader state; only their registrations are invocation-local.
- Microsoft Agent Framework (MAF) — Agent creation, tool registration, and orchestration
- Azure AI Voice Live — Realtime speech-to-speech voice conversations
- A2A Protocol — Agent-to-agent communication over JSON-RPC + SSE streaming
- Aspire — Distributed app orchestration, service discovery, observability
- Azure AI Foundry — LLM backend, hosted Responses agent, prompt agent, web search, and realtime deployment
- Vite + React — Frontend dashboard
- Azure Cosmos DB — Conversation thread persistence
The article and deck compare the two advisor paths using three fresh-conversation
pairs at 56f453a: mean elapsed 15.480 s A2A versus 6.348 s skills, with
11,134 versus 13,533 whole-system tokens across the three runs. Skills used
three model calls per request; A2A used six, six, and seven including remote
specialists. Reused processes, prompt-cache variation, first-use credential
probes, live data, and different model-selected work matter. These observations
are not a controlled architectural speedup, quality study, or billable-cost ratio.
- Architecture: component and protocol diagrams.
- Native MAF migration article: implementation
walkthrough and paired measurements using
considering weather and waiting time, where should i start?. - Presentation source: English Reveal.js deck. Aspire serves
it as
slides; runnpm run buildfromslides/to generate the Vite build.