Sr. Content Developer at Microsoft, working remotely in PA, TechBash conference organizer, former Microsoft MVP, Husband, Dad and Geek.
159240 stories
·
33 followers

Act faster with our new Slack integration

1 Share

If a deployment fails in Octopus and no one is around to see it, does it make a sound?

The gap between an event happening and the right person knowing about it is where deployments quietly stall. We've had consistent customer feedback that Octopus is one of many tools you use, and it's easy to miss when something requires your approval or intervention.

We've built a Slack integration so Octopus can tell your team what needs action, in the tool they already have open. There are two ways to use it:

  1. Subscriptions: for event notifications that can be scoped to the teams that care about them
  2. Send a Slack Message step: for Slack messages sent from a specific point in a deployment or runbook process

Subscriptions: our notification engine

Subscriptions are an existing feature that let users subscribe to events in Octopus. In addition to webhooks and email notifications, you can now have these updates sent to a Slack channel.

:img{ src="/blog/img/slack-integration/variable-changes.png" alt="Screenshot showing a subscription that is tracking changes to varaibles in two specfied projects" loading="lazy" }

A couple of suggested Slack channels to help you keep up to date with important events in Octopus without constant monitoring are:

  • #deployment-failures that captures all deployment failures in one place
  • #deployments-variables-changed that notifies when a variable has been updated
  • #octopus-api-expiries that gives you warnings prior to API expiry events
  • #octopus-mcp-oauth that informs when MCP authorization tokens are issued

:img{ src="/blog/img/slack-integration/variable-changes-notification.png" alt="Screenshot showing a notification in Slack that a variable has changed" loading="lazy" }

If you want your teams to be able to narrow notifications, event filters let you drill down into resources like projects, tags, and environments, so you can update a team channel with events they care about. Keeping your audience narrow and the notifications relevant prevents a mass muting event that makes the integration ineffective.

Slack steps made simple

We have a number of community steps that enable messages to Slack but the set up is more complex, our new Octopus Send a Slack Message step uses the oAuth integration so all you need to do is choose a channel and a message to send.

:img{ src="/blog/img/slack-integration/slack-step.png" alt="Screenshot showing a process with a slack step" loading="lazy" }

This step is particularly useful when placed immediately before a manual intervention step. The message goes out the moment the deployment reaches that point, so the approver knows to go and unblock it. The message field supports Slack markdown and Octopus variables, so you can include the project, release number, environment, and a link straight to the deployment.

:img{ src="/blog/img/slack-integration/slack-notification.png" alt="Screenshot showing a process with a slack step" loading="lazy" }

Learn more

Learn more about setting up the Slack integration here.

What's next for notifications?

  • We're gauging interest in a Microsoft Teams integration so if you're a Teams team comment below.
  • Notify and act: we're adding webhooks triggers to runbooks so you can use the webhook event in subscriptions to trigger a runbook. Follow along here.

Happy deployments!

Read the whole story
alvinashcraft
just a second ago
reply
Pennsylvania, USA
Share this story
Delete

How Artificial Intelligence Disrupts Engineering Progression

1 Share

AI is disrupting career progression by eliminating the learning opportunities at each rung while simultaneously enabling people to perform above their experience level, Alasdair Allan explained in his talk Engineering Progression When AI Ate the Middle at QCon London. Fewer junior developers join the industry, and AI slows hiring at the entry level.

By Ben Linders
Read the whole story
alvinashcraft
25 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Running the same SQL checks in a browser, CLI and pull request

1 Share

I wanted one set of SQL checks to work in three places: while exploring a query, from a terminal and during code review.

That became SQL Atlas. It is a local, deterministic SQL analyzer with a browser interface, a CLI and a GitHub Action. This article covers the interfaces, the CI contract and the limits of static SQL analysis.

One analyzer, three interfaces

The analyzer returns structured data instead of printing messages directly. Each interface decides how to present the same result:

  • The browser explains findings and links them to learning material.
  • The CLI returns text, JSON or Markdown and uses stable exit codes.
  • The GitHub Action converts findings into file annotations and a job summary.

Keeping presentation outside the analyzer prevents the CLI and Action from becoming separate implementations with different behavior.

A CLI needs a contract

The CLI accepts one or more files, or SQL through standard input:

npx --yes sql-atlas@0.5.1 analyze query.sql
echo "SELECT * FROM customers;" | npx --yes sql-atlas@0.5.1 analyze -

It supports PostgreSQL, MySQL, Oracle, SQLite, SQL Server and a generic mode. Output can be text for a person, JSON for another program or Markdown for an issue or report.

Exit codes are part of the interface:

  • 0 means analysis completed and the configured policy passed.
  • 1 means analysis completed but a severity or score threshold failed.
  • 2 means the command or input was invalid.

This distinction matters in CI. A policy failure is not the same as a broken invocation.

Turning findings into pull request feedback

The Action runs as a bundled Node 24 program and does not download dependencies at runtime. A minimal workflow looks like this:

name: SQL review

on:
  pull_request:
    paths:
      - "**/*.sql"

permissions:
  contents: read

jobs:
  sql-atlas:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: milekv/sql-atlas@v0.5.1
        with:
          paths: |
            migrations/**/*.sql
            schema/**/*.sql
          dialect: postgresql
          fail-on: critical
          min-score: 60

Findings become GitHub file annotations. The full result is written to the job summary, and the Action exposes file count, finding count and lowest score as outputs.

The default policy only fails on critical findings. Teams can start in report-only mode with fail-on: none, inspect false positives and add stricter thresholds later.

What static analysis cannot know

SQL Atlas does not connect to a database. It cannot know table sizes, data distribution, available indexes, planner settings or the real execution plan.

For that reason, a warning such as a function applied to a filtered column means "check whether this blocks the index strategy you expect", not "this query is slow". Runtime performance still needs EXPLAIN, representative data and production-like measurements.

The browser includes a local PostgreSQL EXPLAIN JSON viewer for that next step, but the analyzer deliberately keeps its claims narrow.

Testing the distribution surfaces

The project tests the analyzer and both automation interfaces. CI builds the web app, CLI and Action bundle. A smoke workflow runs the repository's own Action against a known SQL file and verifies its outputs. CI also rebuilds the committed Action bundle and checks that it has no uncommitted difference.

The CLI package has no runtime dependencies. I verified the public npm package from an empty directory with a clean cache, including the executable version and a real stdin analysis.

Try it

I am particularly interested in examples where a rule is too broad, misses a dialect detail or produces an unhelpful CI annotation.

Read the whole story
alvinashcraft
1 minute ago
reply
Pennsylvania, USA
Share this story
Delete

Word wagons and bobbasheelys: Life at DARE, with Joan Houston Hall

1 Share

1211. Today, we look at the behind-the-scenes history of the Dictionary of American Regional English with Joan Houston Hall. We look at her personal journey into linguistics, the "word wagons" that traveled the country to collect oral histories, and the fascinating origins of regional words like "scrid" and "bobbasheely." Finally, Joan shares her top three book recommendations for fellow word lovers. This episode originally ran in March for the Grammarpaloozians. To get bonus content like this, custom crosswords, and fun tidbits from Mignon, go to Patreon.com/GrammarGirl.


"Dictionary of American Regional English" (DARE)

Support DARE by visiting the University of Wisconsin's giving page.


πŸ”— Join the Grammar Girl Patreon.

πŸ”— Share your familect recording in Speakpipe or by leaving a voicemail at 833-214-GIRL (833-214-4475)

πŸ”— Watch my LinkedIn Learning writing courses.

πŸ”— Subscribe to the newsletter.

πŸ”— Find an edited transcript.

πŸ”— Get Grammar Girl books.

| HOST: Mignon Fogarty

| Grammar Girl is part of the Quick and Dirty Tips podcast network.

  • Audio Engineer: Dan Feierabend
  • Director of Podcast: Holly Hutchings
  • Advertising Operations Specialist: Morgan Christianson
  • Marketing and Video: Nat Hoopes, Rebekah Sebastian
  • Podcast Associate: Maram Elnagheeb

| Theme music by Catherine Rannus.

| Grammar Girl Social Media: YouTubeTikTokFacebookThreadsInstagramLinkedInMastodonBluesky.


Hosted on Acast. See acast.com/privacy for more information.





Download audio: https://sphinx.acast.com/p/open/s/69c1476c007cdcf83fc0964b/e/6a75e6cb7811de82a54da700/media.mp3
Read the whole story
alvinashcraft
2 hours ago
reply
Pennsylvania, USA
Share this story
Delete

Governing a Risk Operations Agent with the Microsoft Agent Framework Harness and AGT

1 Share

Introduction

I hope you have already found Auditing and Telemetry for the Agent Governance Toolkit - Getting Started with .NET Core. This article demonstrates how you can leverage the Agent Governance Toolkit practically.

This time, I'll take it a step further and combine the Microsoft Agent Framework Harness with AGT. The example is a file-access agent that operates on a local working directory.

As soon as you let an agent touch files, a familiar set of requirements shows up:

  • Listing, reading, and searching files should be allowed
  • Creating, deleting, and overwriting files should not be
  • That decision shouldn't be left entirely to the agent's prompt
  • Blocked tool calls need to show up in an audit log

This is exactly the kind of split where Harness answers "what can the agent do" and AGT answers "should this particular call be allowed."

The sample referenced throughout this post is here:

https://github.com/normalian/MyAGTSamples/tree/main/AGTPolicywithMAFApp03

Dividing responsibility between Harness and AGT

Let's start by separating the two components' jobs.

ComponentResponsibility
Agent Framework HarnessBundles the capabilities and tools an agent can use β€” file access, in this case
Agent Governance ToolkitEvaluates each tool call against policy, allows or denies it, and emits the decision as an event

Harness is what shapes an agent's capabilities. In the sample, AsHarnessAgent() is given a FileSystemAgentFileStore, which exposes tools like file_access_ls, file_access_read, and file_access_grep to the agent.

But just because Harness exposes a tool doesn't mean every call to it should execute. Applying AGT's .WithGovernance() afterward inserts a governance check into the pipeline right before the agent actually invokes a tool.

In other words, it's not enough to tell the model "please don't delete anything" in the prompt β€” you can deny the delete tool at the execution layer even if the agent calls it.

A closer look at the Microsoft Agent Framework Harness

Before going further, it's worth understanding what Harness itself actually does.

Microsoft.Agents.AI.Harness isn't just a package that bolts on some file-operation tools. It's an extension that assembles, as a ready-made pipeline, the pieces that long-running, repeatedly-tool-calling agents tend to need.

A plain Agent Framework AIAgent already lets you configure tools, conversation history, the execution loop, and context management individually. But if every application has to reimplement the following on its own, the code gets complicated fast:

  • The loop that executes function calls returned by the model and feeds results back
  • Persisting conversation history that includes tool execution
  • Compacting context that grows during long-running tasks
  • Auxiliary capabilities like todos, file access, and sub-agents
  • An approval flow before a tool actually executes

Harness wires these long-task building blocks together, taking you from an IChatClient all the way to an AIAgent.

What's assembled inside HarnessAgent

The reference material describes Harness's core as three layers:

IChatClient β”‚ β”œβ”€ FunctionInvokingChatClient β”‚ └─ the automatic loop that runs function calls and returns results β”‚ β”œβ”€ PerServiceCallChatHistoryPersistingChatClient β”‚ └─ persists history per service call β”‚ └─ AIContextProviderChatClient └─ context management via Context Provider + compaction

Instead of wiring these together by hand, you call a single extension method:

AIAgent agent = chatClient.AsHarnessAgent( maxContextWindowTokens, maxOutputTokens, new HarnessAgentOptions { Name = "GovernedFileAccessAgent", ChatOptions = new ChatOptions { Instructions = "An agent that investigates files.", Tools = [/* custom tools */], }, });

Given an IChatClient, AsHarnessAgent() is the entry point for building a HarnessAgent designed for long-running tasks. In this sample, that call also takes a FileSystemAgentFileStore plus several additional options.

Microsoft.Agents.AI.Harness and the related AIContextProvider were experimental at the time this reference material was written. API shapes, tool names, and package versions may change, so check the official docs and release notes for the version you're using.

Compacting the context window

In a long-running agent, user instructions, model responses, function calls, and function results all accumulate in history. As that history approaches the model's context limit, there's no room left for new tool calls or results.

Harness computes an input budget from maxContextWindowTokens and maxOutputTokens:

const int maxContextWindowTokens = 1_050_000; const int maxOutputTokens = 128_000; AIAgent agent = chatClient.AsHarnessAgent( maxContextWindowTokens, maxOutputTokens, new HarnessAgentOptions { /* ... */ });

Conceptually, the input budget available for conversation history and tool results is whatever's left of the model's context limit after reserving room for the next response. As history grows, Harness's compaction mechanism compresses older history while trying to preserve what later reasoning still needs.

That removes a lot of the code you'd otherwise write to manually truncate messages or summarize old tool results yourself. That said, compaction isn't "preserve everything perfectly" β€” if there's state you can't afford to lose, it's worth saving it explicitly through structured mechanisms like todos or file-based memory.

Sample project layout

Here's the shape of the sample:

AGTPolicywithMAFApp03/ β”œβ”€β”€ AGTPolicywithMAFApp03.csproj β”œβ”€β”€ Program.cs β”œβ”€β”€ policies/ β”‚ └── default.yaml └── working/ β”œβ”€β”€ sample.txt └── notes/ └── details.txt

working is the file store the agent operates against. The project file copies both the policy and this directory into the build output:

<ItemGroup> <None Include="policies\default.yaml" CopyToOutputDirectory="PreserveNewest" /> <None Include="working\**\*" CopyToOutputDirectory="PreserveNewest" /> </ItemGroup>

Paths are built relative to AppContext.BaseDirectory at runtime, so the sample doesn't depend on the current working directory.

Environment and packages

The sample targets .NET 10. Main packages (versions as of when the sample was written):

<PackageReference Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" /> <PackageReference Include="Azure.Identity" Version="1.21.0" /> <PackageReference Include="Microsoft.AgentGovernance" Version="5.0.0" /> <PackageReference Include="Microsoft.AgentGovernance.Extensions.Microsoft.Agents" Version="5.0.0" /> <PackageReference Include="Microsoft.Agents.AI" Version="1.17.0" /> <PackageReference Include="Microsoft.Agents.AI.Harness" Version="1.17.0" /> <PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.17.0" />

Set the Azure OpenAI endpoint and sign in with the Azure CLI:

export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5-mini" az login

On Windows PowerShell:

$env:AZURE_OPENAI_ENDPOINT = "https://your-resource.openai.azure.com/" $env:AZURE_OPENAI_DEPLOYMENT_NAME = "gpt-5-mini" az login

Because the sample uses AzureCliCredential, there's no key to embed in the application.

Controlling file operations with policy

policies/default.yaml allows the read-oriented tools and denies the mutating ones:

apiVersion: governance.toolkit/v1 version: "1.0" name: governed-file-access-policy # Anything not explicitly denied is allowed in this sample default_action: allow rules: - name: allow-file-access-read condition: "tool_name == 'file_access_read'" action: allow priority: 10 - name: allow-file-access-ls condition: "tool_name == 'file_access_ls'" action: allow priority: 10 - name: allow-file-access-grep condition: "tool_name == 'file_access_grep'" action: allow priority: 10 - name: deny-file-access-write condition: "tool_name == 'file_access_write'" action: deny priority: 100 - name: deny-file-access-replace condition: "tool_name == 'file_access_replace'" action: deny priority: 100 - name: deny-file-access-replace-lines condition: "tool_name == 'file_access_replace_lines'" action: deny priority: 100 - name: deny-file-access-delete condition: "tool_name == 'file_access_delete'" action: deny priority: 100

The important part here is the combination of default_action and priority.

Because this sample uses default_action: allow, any tool not covered by a rule is allowed by default. Mutating tools then get a higher-priority deny. Since ConflictStrategy.DenyOverrides is also configured, a deny wins whenever an allow and a deny conflict.

For a stricter whitelist approach, switch to default_action: deny and explicitly allow only the tools you need. In production, that's worth considering β€” a whitelist means a newly added tool (from a Harness version bump, for example) doesn't get executed by accident.

Note that tool_name is the function name Harness exposes. file_access_read and file_access_Read are different names, so the strings in your policy need to match the actual tool names exactly.

Wiring AGT into the Harness agent

The core of Program.cs:

using AgentGovernance; using AgentGovernance.Extensions.Microsoft.Agents; using AgentGovernance.Policy; using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException( "AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5-mini"; var workingDirectory = Path.Combine(AppContext.BaseDirectory, "working"); var kernel = new GovernanceKernel(new GovernanceOptions { PolicyPaths = [ Path.Combine(AppContext.BaseDirectory, "policies", "default.yaml") ], ConflictStrategy = ConflictResolutionStrategy.DenyOverrides, }); kernel.OnAllEvents(evt => { Console.WriteLine( $"[Governance] Type: {evt.Type}, " + $"Tool: {evt.PolicyName}, Agent: {evt.AgentId}"); }); AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new AzureCliCredential()) .GetChatClient(deploymentName) .AsIChatClient() .AsHarnessAgent(new HarnessAgentOptions { Name = "GovernedFileAccessAgent", Description = "Demonstrates governed read-only access to sample files.", FileAccessStore = new FileSystemAgentFileStore(workingDirectory), FileAccessProviderOptions = new FileAccessProviderOptions { DisableReadOnlyToolApproval = true, DisableWriteToolApproval = true, }, DisableTodoProvider = true, DisableAgentModeProvider = true, DisableAgentSkillsProvider = true, DisableFileMemory = true, DisableWebSearch = true, ChatOptions = new ChatOptions { Instructions = """ You are a file access governance demonstration agent. Use the file_access_* tools to inspect the sample files. Read operations are allowed. Write, delete, and replace operations are denied by governance. """, }, }) .WithGovernance( kernel, new AgentFrameworkGovernanceOptions { DefaultAgentId = "governed-file-access-agent", EnableFunctionMiddleware = true, BlockedToolResultFactory = toolResult => { Console.WriteLine( $"[BLOCKED by Governance] " + $"{toolResult.AuditEntry.PolicyName}: " + $"{toolResult.Reason}"); return $"Tool call blocked by governance policy: " + $"{toolResult.Reason}"; }, });

What AsHarnessAgent() provides

Configuring AsHarnessAgent() with a FileSystemAgentFileStore gives the agent access to the file-operation tools.

This sample turns off everything else Harness offers:

DisableTodoProvider = true, DisableAgentModeProvider = true, DisableAgentSkillsProvider = true, DisableFileMemory = true, DisableWebSearch = true,

That's just to keep the demo focused on file access. In a real application, enable only the capabilities you need, and write a policy for each corresponding tool β€” the more capabilities you turn on, the more surface area your policy and audit log need to cover.

FileAccessProviderOptions vs. AGT

DisableReadOnlyToolApproval and DisableWriteToolApproval control Harness's own approval flow. Turning them off does not disable AGT's policy evaluation.

Harness answers "how is the tool exposed," AGT answers "should this call to it execute." An approval UI or human-in-the-loop check and a policy-enforced deny aren't alternatives to each other β€” they're two separate layers of defense you can combine.

.WithGovernance() is the boundary

This is the part that actually joins the two together:

.WithGovernance( kernel, new AgentFrameworkGovernanceOptions { DefaultAgentId = "governed-file-access-agent", EnableFunctionMiddleware = true, })

With EnableFunctionMiddleware = true, AGT intercepts the agent's function calls. Even if the model's reasoning concludes "write this file," the policy is consulted before the tool actually runs β€” and if that's a deny, it doesn't run.

BlockedToolResultFactory lets the application control what gets returned to the agent on a deny β€” useful for surfacing a user-facing explanation or attaching an audit ID.

Two agent IDs, on purpose

The sample deliberately uses two different identifiers.

Name = "GovernedFileAccessAgent"

This is the Harness / Agent Framework side's agent name.

DefaultAgentId = "governed-file-access-agent"

This is the identifier AGT uses for policy evaluation and audit events.

They don't need to match. In fact, keeping the framework-level display name separate from a stable governance/audit ID means you can rename the display name without losing continuity in your audit trail. If you're running multiple agents, it's worth deciding on a unique ID scheme up front that accounts for tenant and environment.

Running it

The sample walks through read, write, and delete in sequence:

Console.WriteLine("=== Allowed read ==="); await RunAndPrintAsync( agent, "Use file_access_ls and file_access_read to list " + "and read sample.txt. Do not modify any files."); Console.WriteLine("\n=== Blocked write ==="); await RunAndPrintAsync( agent, "Use file_access_write to create blocked-write.txt " + "with the text 'this write must be denied'."); Console.WriteLine("\n=== Blocked delete ==="); await RunAndPrintAsync( agent, "Use file_access_delete to delete sample.txt. " + "This operation must be denied by governance.");

Run it with:

dotnet run --project AGTPolicywithMAFApp03/AGTPolicywithMAFApp03.csproj

Expected results:

OperationAGT decisionEffect on files
file_access_ls / file_access_readallowsample.txt can be listed and read
file_access_writedenyblocked-write.txt is never created
file_access_deletedenysample.txt is never deleted

On a denial, BlockedToolResultFactory returns something like:

Tool call blocked by governance policy: ...

What matters isn't just that the agent gets an explanation β€” it's that the denied tool never actually executes. Even if a prompt injection or similar attack changes the agent's instructions mid-conversation, the write/delete boundary stays enforced at the execution layer as long as the policy is in place.

Using governance events for auditing

The sample writes every event to the console:

kernel.OnAllEvents(evt => { Console.WriteLine( $"[Governance] Type: {evt.Type}, " + $"Tool: {evt.PolicyName}, Agent: {evt.AgentId}"); });

That's fine for local development, but in production you'd want to send these to Application Insights or OpenTelemetry. At minimum, it's worth capturing:

  • Agent ID
  • Tool name
  • The policy that applied
  • The allow/deny outcome
  • The reason for denial
  • A correlation ID for the originating request

Also design your logging so file contents and other sensitive arguments aren't written verbatim β€” separate what you need for auditing from data you shouldn't be persisting at all.

Choosing between default_action: allow and deny

The sample uses default_action: allow to keep the walkthrough simple: allow everything Harness's read/write/delete/replace tools expose, and deny only the dangerous ones explicitly.

In a real deployment, though, the two modes serve different purposes:

Deny-list approach

default_action: allow

Broadly permissive, with specific dangerous operations explicitly blocked. Easy to adopt, but a newly introduced tool could end up allowed by default.

Allow-list approach

default_action: deny

Every permitted tool has to be listed explicitly. More setup up front, but unknown tools fail safe.

If the agent is going to operate against production data, default_action: deny is the safer baseline β€” start from nothing allowed and grant read access (with path conditions) incrementally. Combine that with per-agent IDs, per-environment policies, rate limiting, and circuit breakers for a stronger boundary overall.

Why this combination is worth it

Pairing Harness with AGT gives you four main benefits:

  1. Capability and permission are separated. You can change what's executable through YAML policy alone, without touching the Harness configuration.
  2. Denial happens at the execution layer, not the prompt. Deny rules apply at the tool-call boundary regardless of how the agent's instructions or conversation history change.
  3. Decisions are auditable. Which agent, which tool call, which rule it matched, and why it was denied β€” all available as events.
  4. Approval flows and enforced policy can coexist. Use Harness's approval features for UX, and AGT as the safety boundary that can't be crossed.

None of this is specific to file access. The same pattern applies anywhere an agent is delegated a hard-to-reverse action β€” database writes, external API calls, closing a ticket, sending an email.

Summary

This sample built a read-only file-access agent through the following steps:

  1. Expose file-operation tools through Harness's FileAccessProvider
  2. Allow reads and deny write/delete/replace in AGT's YAML policy
  3. Insert the policy check into tool calls via .WithGovernance() and function middleware
  4. Use governance events and blocked-call results for auditing and user-facing feedback

Harness gives an agent capability; the Agent Governance Toolkit draws the boundary around that capability. Getting an AI agent closer to production isn't just about adding useful tools β€” it's equally about being able to control, from outside the agent's own code, when, by whom, and under what conditions each tool can actually run.

Sample code for this post:

https://github.com/normalian/MyAGTSamples/tree/main/AGTPolicywithMAFApp03

References

Read the whole story
alvinashcraft
2 hours ago
reply
Pennsylvania, USA
Share this story
Delete

Add AI to the workflows you already have using Serverless Agents in Azure Functions

1 Share

There are a lot of ways to build with AI right now: chat frontends, copilots, greenfield agent apps, orchestration frameworks. All of them have their place, and some customers are building entirely new applications this way. But across customer engagements, a consistent pattern has emerged: the most successful and most cost-effective AI projects are not rewrites. They are existing, deterministic, event-driven business workflows (queue processing, message handling, scheduled jobs) with AI added at exactly the one step that was never deterministic to begin with. This enables the parts that are battle hardened to remain as before, adding AI where non-deterministic smarts are needed. This creates a more robust application along with spending costs for tokens only where beneficial.

That pattern has a natural home in Azure Function and Serverless Agents runtime now support non-Http triggers.

This post walks through the pattern in three layers: the app you already have, what it takes to add AI processing onto it yourself, and what it looks like with Serverless Agents. Learn and try it here: Build serverless agents using Azure Functions | Microsoft Learn

The scenario: expense processing

Picture an expense approval pipeline. Expense and purchase-order requests arrive as messages on a queue: some as quick notes, some as forwarded emails, some as key-value text or JSON from intake tools. Most of the piping around the decision is deterministic and should stay that way: queueing, retries, policy storage, output queues, identity, and the audit trail. You do not want a language model reimplementing any of that.

But one step in the middle has always resisted automation: understanding the request, choosing the policy that governs it, and applying a natural-language rulebook. "Booked a $450 round-trip flight to Denver for the customer onsite next week. β€” Albert" Turning that into a structured decision (amount, currency, vendor, category, policy applied, destination queue, reason) is exactly the kind of fuzzy, judgment-shaped work that used to mean either a human in the loop or a brittle pile of regexes and keyword lists.

That one step is the AI part of the equation. Everything else stays as code.

Layer 1: the app you already have

If you're running message-based workloads on Azure Functions today, your expense processor looks something like this, using the standard Python v2 programming model with a queue trigger:

import json import azure.functions as func app = func.FunctionApp() @app.queue_trigger(arg_name="msg", queue_name="expense-requests", connection="AzureWebJobsStorage") def process_expense(msg: func.QueueMessage): expense = json.loads(msg.get_body()) validate_expense(expense) if is_duplicate(expense): return decision = apply_expense_policy(expense) route_decision(decision) write_audit_record(expense, decision)

This is good architecture, and nothing in this post asks you to change it. You get scale-out per message, retries with a poison queue after repeated failures, and scale to zero between messages. On the Flex Consumption plan you pay for execution, not for idle. The queue itself is doing real architectural work: it buffers spikes, absorbs backpressure, and decouples producers from processing.

The limitation is only that process_expense can read only the schema for which it was written. Free text, email-shaped messages, and inconsistent key-value input require a parser before the deterministic policy code can use them, and selecting among category-specific policy documents means encoding more rules in code.

Layer 2: the do-it-yourself middle step

The obvious next move is to call a model from inside the function. The first version is deceptively short:

# Sketch of the DIY approach: this is the version that grows client = get_model_client() # SDK setup, endpoint, credential prompt = build_prompt(expense) # prompt template you now maintain response = client.complete(prompt) # plus retry/backoff for 429s decision = parse_or_die(response) # LLM output isn't always valid JSON

The problem isn't the first version. It's everything the first version turns into. Model SDK and auth wiring. Prompt templates living in Python strings. Retry and backoff logic for rate limits, on top of the queue's own retry semantics. Output parsing and re-prompting when the model returns almost-JSON. Then the requests start arriving: "can it look up the current policy documents?" (now you are building tool-calling), "can it run a calculation?" (now you need somewhere safe to execute generated code), "why did it say that?" (now you are building telemetry for model and tool activity). None of this is your expense pipeline. All of it becomes your code to own, patch, and secure.

This is the middle step where a lot of AI-in-the-workflow projects stall, not because the idea was wrong, but because the glue outgrew the feature.

Layer 3: the same trigger, with the Serverless Agents runtime

The Serverless Agents runtime, collapses that middle layer. An agent is a markdown file, with instructions in the body and the trigger in YAML front matter, and it runs on the same Azure Functions triggers you already use. Here is the complete agent from the expense processor sample, expense_processor.agent.md:

--- name: Expense Processor description: Reads one expense or purchase-order request that arrives on a queue in any format β€” free text, email, key-value, or JSON β€” chooses the spending policy that fits the expense category from a set of policy documents, applies it, and routes the decision. trigger: type: queue_trigger args: queue_name: expense-requests connection: AzureWebJobsStorage data_type: string --- You are an expense-approval agent. Each queue message is **one** expense or purchase-order request as raw text β€” it might be a quick note, an email, `key: value` lines, or JSON. Finance keeps several policy documents in storage: a general policy plus category-specific ones (travel, meals & entertainment, equipment & software). Your job is to understand the request, pick the policy that governs it, and route the decision. For each message: 1. **Extract** the details, whatever the format: `amount` (strip symbols, separators, and words β€” `$1,250`, `1.250,00`, and `twelve hundred dollars` are all numbers), `currency` (default `USD`), `vendor`, `category`, and an `expenseId` (use the one in the message, else generate `EXP-<6 hex>`). 2. **Select the policy.** Call `list_expense_policies` to see each policy and what it covers, then choose the one whose scope matches the expense. Use the general policy when nothing else fits. 3. **Fetch it.** Call `get_expense_policy` with that document's exact name, and apply what it says. 4. **Decide.** Work the policy's rules top to bottom; the first rule that matches wins. The amount is the backbone β€” for an ordinary in-scope USD expense the policy's amount thresholds decide the outcome, applied exactly at the boundaries. Never guess an exchange rate for a non-USD amount. The result is one of three queues: `expense-approved`, `expense-review`, or `expense-flagged`. 5. **Route** by calling `route_expense_decision` **once** with the destination queue and the decision JSON. If it errors, carry on β€” still return the decision. 6. **Respond** with the decision JSON so the outcome shows up in the logs: ```json { "expenseId": "EXP-1001", "vendor": "United Airlines", "category": "travel", "amount": 450.0, "currency": "USD", "policyApplied": "travel-policy.md", "decision": "approve", "routedTo": "expense-approved", "reason": "Travel expense of 450 USD is at or below the travel policy's 1,000 auto-approve threshold." } ``` Base every decision only on the policy you just fetched β€” never on rules remembered from an earlier message. Keep `reason` to one sentence, and always set `policyApplied` to the document you used.

 

 

Note what the front matter is: the same queue_trigger configuration you would pass to the Functions decorator: queue name, connection setting, and string data type. If you know Azure Functions triggers, you already know how to trigger an agent.

The entire function_app.py is bootstrap:

from azure_functions_agents import create_function_app app = create_function_app()

And app-wide defaults live in agents.config.yaml:

# App-wide defaults for every agent in this function app. # # `model` is intentionally NOT set here so the runtime resolves it per provider: # - deployed (foundry provider): FOUNDRY_MODEL app setting (e.g. gpt-5.4) # - local (azure_openai provider): AZURE_OPENAI_DEPLOYMENT setting (e.g. gpt-5.4-mini) # Set AZURE_FUNCTIONS_AGENTS_MODEL to override in any environment. timeout: 900

When a message lands on expense-requests, the runtime invokes the agent once for that one item. The trigger's data_type: string and the host's messageEncoding: "none" keep the raw text human-readable, while the runtime serializes the queue message body and metadata before adding them to the agent prompt. The agent's instructions do fuzzy work; the results show up in your Function App logs and Application Insights like any other execution.

For the expense scenario, the pattern points directly at the intake queue: the agent extracts the amount, currency, vendor, category, and expense ID; calls list_expense_policies and get_expense_policy to choose and read the current policy from Blob Storage; applies the rules; and calls route_expense_decision to send the result to expense-approved, expense-review, or expense-flagged. Queueing, policy storage, identity, and routing stay deterministic; the agent gets responsibility for the part that needs judgment.

What changed between layer 2 and layer 3

Concern

DIY (layer 2)

Serverless Agents (layer 3)

Trigger & scaling

Yours (Functions)

Yours (Functions, unchanged)

Model client, auth, provider config

Your code

Runtime (Foundry, Azure OpenAI, or OpenAI)

Prompt & instructions

Python strings

Markdown agent file

Trigger payload handling

Manual parsing

Raw queue payload and metadata injected by the runtime

Tool calling

Build it yourself

MCP servers, connectors, plain-Python @tool functions

Safe code execution

Build it yourself

Sandboxed via Azure Container Apps dynamic sessions

Model/tool telemetry

Build it yourself

Built-in, flows to Application Insights

Retries & poison handling

Queue semantics + your model retries

Queue semantics, with dequeue_count right in the payload

 

The economics follow from the architecture. On Flex Consumption, the app scales to zero between messages, so you pay when expense requests arrive, and the AI spend is confined to the single step that needs a model, instead of being architected into every request the way a chat-first design tends to force. This is a large part of why the augment-don't-rewrite engagements are the cost-effective ones: the deterministic 90% of the workload keeps running at deterministic-workload prices.

Use all Event Driven triggers

Queue Trigger is one row in a much longer table. The runtime supports the breadth of the Functions trigger model in .agent.md front matter: Service Bus queues and topics, Event Hubs, Event Grid, Blob Storage, Cosmos DB, Azure SQL, Kafka, timers, Dapr bindings, and connector triggers, alongside HTTP

when you do want a chat endpoint. Wherever your events are already flowing, an agent can meet them there.

 

Try it

The sample deploys with the Azure Developer CLI:

git clone https://github.com/Azure-Samples/serverless-agents-expense-processor.git cd serverless-agents-expense-processor azd up

Then send one of the bundled requests to the provisioned expense-requests queue and read the decision queues:

uv run scripts/send_expense.py --file samples/travel.txt --cloud uv run scripts/read_decision.py --queue all --peek --cloud

 

 

The travel request is a $450 flight. Swap samples/travel.txt for samples/client-dinner.txt or samples/equipment.txt to see the same amount, select a different policy and route differently. The sample's README also covers running locally with Azurite and Core Tools.

 

We are working on many more features to make it really easy for you to take your existing apps and make them intelligent, including Hybrid AI apps (your code + AI markdown binding), dynamic workflows and so on.

 

 

 

 

 

Read the whole story
alvinashcraft
2 hours ago
reply
Pennsylvania, USA
Share this story
Delete
Next Page of Stories