Building Event-Driven Agents with Azure Functions

Microsoft now has several ways to build AI agents. You can create prompt agents in Foundry, build agentic workflows with Logic Apps, or deploy your own agent code as a Foundry hosted agent.

There is another option that fits a different type of problem: the Azure Functions serverless agents runtime.

This runtime lets you define agents in Markdown files and run them as Azure Functions. Agents can be triggered by HTTP requests, timers, queues, blobs, database changes, or connector events. The runtime is currently in preview, so features can still change. See the Microsoft documentation for the latest information.

The sample in this post is a scheduled AI news digest. It searches for recent AI news and sends a card to Teams. The news digest is not the important part. It is just a small example that needs a timer, a model, tools, state, and an external notification.

What is the serverless agents runtime?

The serverless agents runtime is a programming model for event-driven agents.

You define the agent, configure its capabilities, and deploy the project as a normal Python Function App. The runtime discovers the agents, registers their triggers, assembles their tools, and runs them using Microsoft Agent Framework.

The complete function_app.py in the sample is this:

from azure_functions_agents import create_function_app

app = create_function_app()

There is no timer registration or explicit agent construction here. The runtime finds that information in the project files when the Functions host starts.

This is the main idea:

event
-> Azure Functions trigger
-> runtime-built agent
-> model and tools
-> result

Defining an agent

The agent is defined in a markdown file: ai_news_digest.agent.md.

---
name: AI News Teams Digest Agent
description: Finds recent AI news and posts a digest.
model: $FOUNDRY_MODEL
trigger:
type: timer_trigger
args:
schedule: "0 0 6 * * *"
---

The Markdown below the front matter becomes the agent instructions:

You are a scheduled AI news digest agent.
When this timer fires:
1. Research recent AI news.
2. Select at most five distinct articles.
3. Call post_ai_news_digest exactly once.

The schedule uses the six-field Azure Functions NCRONTAB format. In this case, the agent runs every day at 06:00 UTC.

The important point is that the timer is part of the agent definition. The application does not need a separate timer function.

Note the the Foundry endpoint is set as an environment variable. It is not in the markdown file. The Function App gets a managed identity which can access the model referenced by $FOUNDRY_MODEL which is also an environment variable. I used gpt-5.6-luna here.

Check https://learn.microsoft.com/en-us/azure/azure-functions/functions-serverless-agents-runtime-reference to learn about the possible settings in the markdown file. I have used only a small subset here.

What happens at startup?

When create_function_app() runs, the runtime discovers:

  • Agent files and their triggers.
  • MCP servers in mcp.json.
  • Custom Python tools in tools/.
  • Reusable skills in skills/, if present.
  • App-wide settings in agents.config.yaml.

When a trigger fires, the runtime resolves the model and instructions, assembles the available tools, loads session information if needed, and executes the agent.

The app-wide configuration in this sample is small:

model: $FOUNDRY_MODEL
timeout: 900

This keeps the model and timeout outside the Python code. Agent-specific settings can still override these defaults in the front matter. There are other settings like a management endpoint for the Azure Container Apps dynamic session pool used by sandbox tools. They are not used here.

An agent is becoming a directory

A useful pattern is emerging across agent runtimes: an agent is becoming a collection of files rather than one large class or prompt.

For example, this sample uses:

ai_news_digest.agent.md
agents.config.yaml
mcp.json
tools/
skills/

Vercel’s eve uses a similar filesystem-first approach with instructions, tools, skills, schedules, and subagents. LangChain’s Managed Deep Agents also uses files such as AGENTS.mdskills/subagents/, and tools.json.

The files define the agent. The runtime provides the operational parts:

agent files + runtime = running agent

The runtime handles things such as tool execution, state, scheduling, sandboxes, and observability.

The Azure Functions runtime’s particular strength is its event model. An agent can start from a timer, HTTP request, queue message, blob event, or connector event.

Adding tools with MCP

The sample uses Tavily for web search through MCP:

{
"servers": {
"tavily": {
"type": "streamable-http",
"url": "$TAVILY_MCP_SERVER_URL"
}
}
}

The application does not contain Tavily client code. The runtime discovers the remote MCP server and makes its tools available to the agent.

The same model can be used with other remote MCP servers, Azure connector tools, custom Python tools, and sandboxed Python execution. The runtime reference describes these capabilities in more detail.

Custom Python tools are useful for application-specific logic. The sample exposes one tool for posting the digest:

from azure_functions_agents import tool
@tool
def post_ai_news_digest(digest: NewsDigest) -> str:
"""Post up to five articles as one Teams card."""
...

The tool validates the articles, removes ones already sent, creates an Adaptive Card, and stores the article IDs in Blob Storage. That is normal application code. The runtime’s job is to discover the tool and make it available to the agent.

Why Azure Functions?

The runtime is useful because it brings the normal Azure Functions features to agents.

With Flex Consumption, the Function App can scale to zero, scale automatically, and use per-second billing. It also supports managed identity, Application Insights, and virtual network integration.

The runtime supports several event types:

  • HTTP requests.
  • Timer triggers.
  • Queue and Service Bus messages.
  • Blob and database events.
  • Connector events.

This makes it a good fit for background agents, monitors, reports, and event processors.

The runtime also manages session history. In Azure, session data is stored through the Function App’s AzureWebJobsStorage account. The application can still keep its own state, as the news sample does with seen-articles.json.

These are two different things:

Runtime state: conversation and session history
Application state: articles already posted by this sample

How does it compare with other agent options?

Microsoft’s comparison guide summarizes the main differences.

OptionMain ideaBest fit
Serverless agents runtimeMarkdown agents running as FunctionsEvent-driven agents
Foundry prompt agentsDeclarative agents managed by FoundryAgents without custom code
Foundry hosted agentsYour code packaged as a containerCustom agent servers and protocols
Logic Apps agent workflowsAn Agent action inside a workflowBusiness process automation

Foundry prompt agents are useful when instructions and configured tools are enough. You do not need to manage a Function App or custom agent process. They were not a good fit for this agent because I needed a bit of custom code to ensure the agent returns unique links.

Foundry hosted agents are better when you need to bring your own code and container. Foundry manages the endpoint, identity, sessions, scaling, and lifecycle of that container. See the hosted agents documentation. A hosted agent would have been overkill for this agent.

Logic Apps is workflow-first. An autonomous workflow can start from a trigger, pass data to an Agent action, and let the agent use tools created from Logic Apps actions. This is useful when approvals, connectors, retries, and business steps are more important than the agent code itself. See the Logic Apps agent workflow documentation.

A simple way to remember the difference is:

Logic Apps: a workflow that contains an agent
Azure Functions: an agent started by a Function trigger
Foundry hosted: your agent running inside a managed container

Deploying the sample

Deployment is still normal Azure Functions deployment:

func azure functionapp publish <FUNCTION_APP_NAME> \
--python \
--build remote \
--force \
--subscription <SUBSCRIPTION_ID>

After deployment, the output should show the discovered timer function:

ai_news_digest - [timerTrigger]

Application Insights can then show the important parts of the run: MCP discovery, model tool calls, the custom Python tool, and the outbound Power Automate request.

Conclusion

The news digest is only a sample workload. The more interesting part is the hosting model.

The Azure Functions serverless agents runtime combines Markdown-based agent definitions with the event model of Azure Functions. It provides triggers, tool discovery, MCP integration, managed identity, session storage, telemetry, and serverless hosting.

Use it when the agent is naturally started by an event. Use Foundry hosted agents when you need a custom container and endpoint. Use Logic Apps when the main problem is business workflow orchestration.

The runtime gives you another useful choice: an agent can be a Function App workload without requiring you to build and operate a complete agent server yourself.

Further improvements to the IoT Hub to TimescaleDB Azure Function

In the post Improving an Azure Function that writes IoT Hub data to TimescaleDB, we added some improvements to an Azure Function that uses the Event Hub trigger to write messages from IoT Hub to TimescaleDB:

  • use of the Event Hub enqueuedTime timestamp instead of NOW() in the INSERT statement (yes, I know, using NOW() did not make sense 😉)
  • make the code idempotent to handle duplicates (basically do nothing when a unique constraint is violated)

In general, I prefer to use application time (time at the event publisher) versus the time the message was enqueued. If you don’t have that timestamp, enqueuedTime is the next best thing.

How can we optimize the function even further? Read on about the cardinality setting!

Event Hub trigger cardinality setting

Our JavaScript Azure Function has its settings in function.json. For reference, here is its content:

{
"bindings": [
{
"type": "eventHubTrigger",
"name": "IoTHubMessages",
"direction": "in",
"eventHubName": "hub-pg",
"connection": "EH",
"cardinality": "one",
"consumerGroup": "pg"
}
]
}

Clearly, the function uses the eventHubTrigger for an Event Hub called hub-pg. In connection, EH refers to an Application Setting which contains the connections string to the Event Hub. Yes, I excel at naming stuff! The Event Hub has defined a consumer group called pg that we are using in this function.

The cardinality setting is currently set to “one”, which means that the function can only process one message at a time. As a best practice, you should use a cardinality of “many” in order to process batches of messages. A setting of “many” is the default.

To make the required change, modify function.json and set cardinality to “many”. You will also have to modify the Azure Function to process a batch of messages versus only one:

Processing batches of messages

With cardinality set to many, the IoTHubMessages parameter of the function is now an array. To retrieve the enqueuedTime from the messages, grab it from the enqueuedTimeUtcArray array using the index of the current message. Notice I also switched to JavaScript template literals to make the query a bit more readable.

The number of messages in a batch is controlled by maxBatchSize in host.json. By default, it is set to 64. Another setting,prefetchCount, determines how many messages are retrieved and cached before being sent to your function. When you change maxBatchSize, it is recommended to set prefetchCount to twice the maxBatchSize setting. For instance:

{
"version": "2.0",
"extensions": {
"eventHubs": {
"batchCheckpointFrequency": 1,
"eventProcessorOptions": {
"maxBatchSize": 128,
"prefetchCount": 256
}
}
}
}

It’s great to have these options but how should you set them? As always, the answer is in this book:

Afbeeldingsresultaat voor it depends joke

A great resource to get a feel for what these settings do is this article. It also comes with a Power BI report that allows you to set the parameters to see the results of load tests.

Conclusion

In this post, we used the function.json cardinality setting of “many” to process a batch of messages per function call. By default, Azure Functions will use batches of 64 messages without prefetching. With the host.json settings of maxBatchSize and prefetchCount, that can be changed to better handle your scenario.

Azure Functions with Consumption Plan on Linux

In a previous post, I talked about saving time-series data to TimescaleDB, which is an extension on top of PostgreSQL. The post used an Azure Function with an Event Hub trigger to save the data in TimescaleDB with a regular INSERT INTO statement.

The Function App used the Windows runtime which gave me networking errors (ECONNRESET) when connecting to PostgreSQL. I often encounter those issues with the Windows runtime. In general, for Node.js, I try to stick to the Linux runtime whenever possible. In this post, we will try the same code but with a Function App that uses the Linux runtime in a Consumption Plan.

Make sure Azure CLI is installed and that you are logged in. First, create a Storage Account:

az storage account create --name gebafuncstore --location westeurope --resource-group funclinux --sku Standard_LRS

Next, create the Function App. It references the storage account you created above:

az functionapp create --resource-group funclinux --name funclinux --os-type Linux --runtime node --consumption-plan-location westeurope --storage-account gebafuncstore

You can also use a script to achieve the same results. For an example, see
https://docs.microsoft.com/en-us/azure/azure-functions/scripts/functions-cli-create-serverless.

Now, in the Function App, set the following Application Settings. These settings will be used in the code we will deploy later.

  • host: hostname of the PostgreSQL server (e.g. servername.postgres.database.azure.com)
  • user: user name (e.g. user@servername)
  • password
  • database: name of the PostgreSQL database
  • EH: connection string to the Event Hub interface of your IoT Hub; if your are unsure how to set this, see this post

You can set the above values from the Azure Portal:

Application Settings of the Function App

The function uses the first four Application Settings in the function code via process.env:

Using Application Settings in JavaScript

The application setting EH is used to reference the Event Hub in function.json:

function.json with Event Hub details such as the connection, cardinality and the consumerGroup

Now let’s get the code from my GitHub repo in the Azure Function. First install Azure Function Core Tools 2.x. Next, create a folder called funcdemo. In that folder, run the following commands:

git clone https://github.com/gbaeke/pgfunc.git
cd pgfunc
npm install
az login
az account show

The npm install command installs the pg module as defined in package.json. The last two commands log you in and show the active subscription. Make sure that subscription contains the Function App you deployed above. Now run the following command:

func init

Answer the questions: we use Node and JavaScript. You should now have a local.settings.json file that sets the FUNCTIONS_WORKER_RUNTIME to node. If you do not have that, the next command will throw an error.

Now issue the following command to package and deploy the function to the Function App we created earlier:

func azure functionapp publish funclinux

This should result in the following feedback:

Feedback from function deployment

You should now see the function in the Function App:

Deployed function

To verify that the function works as expected, I started my IoT Simulator with 100 devices that send data every 5 seconds. I also deleted all the existing data from the TimescaleDB hypertable. The Live Metrics stream shows the results. In this case, the function is running smoothly without connection reset errors. The consumption plan spun up 4 servers:

Live Metrics Stream of IoT Hub to PostgreSQL function