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

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
22 minutes 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
22 minutes 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
22 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

Building MCP servers for your database: Flexibility, safety, and tradeoffs

1 Share

Model Context Protocol (MCP) is an open protocol that describes how agents can connect to external tools and data sources, and is now widely supported by the most popular coding agents (like GitHub Copilot, Claude Code, and Codex) and agent frameworks (like LangChain and Pydantic AI).

If you want to give agents a standard way to access the data in a database, you can build your own MCP server and expose tools for the agent to query or even modify data. But you need to design your MCP server carefully, to ensure that agents can do everything that users want - but nothing that you don't want them to do!

In this post, we'll walk through a range of ways to build MCP servers on top of a PostgreSQL database, since PostgreSQL is the most popular open source database and is production-ready with hosted offerings like Azure Database for PostgreSQL. These approaches can be used with any database, however.

We'll start with the most flexible option, exploratory servers that allow the agent to generate full SQL queries, conclude with the strictest option, fully typed tools for templated queries, and explore options in the middle too.

 

Free-form SQL

Let's take a look at a simple MCP server that gives the agent as much information and control as possible. For all of our examples, we use the Python language and the FastMCP package, but SDKs are available in multiple languages. All code is available in the GitHub repository.

We start off by giving the server a name, which the agent will see and consider when deciding which MCP server to invoke for a given user query:

mcp = FastMCP("Bees database MCP server")

For this example, my database stores observations of bees, so I name it accordingly.

We then define an execute_sql tool that accepts any SQL string, executes it against the database, and returns the rows:

@mcp.tool() async def execute_sql(sql: str) -> str: """Execute a SQL query against the database and return results.""" engine = await _get_engine() async with engine.connect() as conn: result = await conn.execute(text(sql)) if result.returns_rows: columns = list(result.keys()) rows = result.fetchall() return {"columns": columns, "rows": [[str(v) for v in row] for row in rows]} await conn.commit() return f"Statement executed. Rows affected: {result.rowcount}"

How will the agent know what SQL can be passed into that tool, however? We need to give it a way to discover the schema, so we also define a get_db_schema tool that dumps out the entire schema with table names, columns, and data types:

@mcp.tool() async def get_db_schema() -> str: """Return the database schema for all public tables.""" engine = await _get_engine() return await get_db_schema_text(engine)

We can test this MCP server out with a coding agent like GitHub Copilot. When we ask the agent "Which bees are active in El Cerrito in April?", the agent realizes that the Bees MCP server has relevant tools for the task, first calls get_db_schema, then calls execute_sql with a SELECT query. The database returns the results and the agent formats them into a Markdown table:

This MCP server works — we got the answer we wanted — but as you may have already noticed, there are multiple problems with this approach.

Problem: Schema bloat

Let's tackle the problem with the get_db_schema tool first - it dumps everything! My observations database has only 5 tables and 60 columns, but a production database may have hundreds of tables and thousands of columns. Dumping the entire schema can confuse the LLM with irrelevant information, and unnecessarily fill up its context window.

What can we do instead? Progressive schema discovery. We provide two tools: list_tables that only returns table names, and describe_table that returns the columns only for the given table:

@mcp.tool() async def list_tables() -> str: """List all tables in the public schema. Call this first to discover available tables.""" async with engine.connect() as conn: result = await conn.execute(text( "SELECT table_name FROM information_schema.tables " "WHERE table_schema = 'public' AND table_type = 'BASE TABLE'")) return {"tables": [row[0] for row in result.fetchall()]} @mcp.tool() async def describe_table(table_name: str) -> str: """Describe the columns of a specific table. Call list_tables() first to see available tables.""" async with engine.connect() as conn: result = await conn.execute(text( "SELECT column_name, data_type, is_nullable FROM information_schema.columns " "WHERE table_schema = 'public' AND table_name = :table_name "), {"table_name": table_name}) rows = result.fetchall() columns = [{"name": col, "type": dt, "nullable": n == "YES"} for col, dt, n in rows] return {"table": table_name, "columns": columns}

When we expose these tools to GitHub Copilot, the agent first calls list_tables, then makes two calls to describe_table, one for each relevant table: 

The agent requires 3 tool calls for schema discovery instead of the single call required before, so this server design can increase latency. However, for databases with large schemas, it prevents context bloat. You can decide based on schema size whether the tradeoff is worth it.

Problem: Mutations without guardrails

Now let's tackle the destructive elephant in the room: execute_sql can execute any valid SQL, including updates and deletions. If a user asks the agent, "How many bee observations have quality grade 'needs_id'? Might want to delete", it might just delete thousands of rows with a single DELETE statement. If that's okay with you, great, but for many scenarios, you'll want to either completely prevent mutation or at least require confirmation first.

Read-only SQL tool

Let's start by making a read-only version of the SQL execution tool. The execute_readonly_sql tool below includes multiple guardrails: a verification that the SQL contains only SELECT, a 30-second timeout to prevent expensive queries, and a maximum of 100 rows:

@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True), timeout=30.0) async def execute_readonly_sql(sql: str) -> dict: """Execute a read-only SQL query against the database. Only SELECT statements are allowed. Non-SELECT statements are rejected. Results are capped at 100 rows.""" try: validated_sql = validate_readonly_sql(sql) except ValueError as e: raise ToolError(str(e)) async with engine.connect() as conn: result = await conn.execute(text(validated_sql)) columns = list(result.keys()) rows = result.fetchmany(MAX_LIMIT) # Cap rows regardless of LIMIT return {"columns": columns, "rows": [[str(v) for v in row] for row in rows]}

Notice the tool is annotated with readOnlyHint=True, one of the allowed annotations from the MCP specification. When we set that read-only hint on a tool, we're sending a signal to the MCP client that this is a tool that does not modify data, which may affect how the client renders the tool or handles approvals. But it is only a hint, not a contract. A server could lie about it, or even unintentionally report it incorrectly. As the server developer, we must enforce actual read-only operations inside the tool logic itself.

That's the goal of validate_readonly_sql: a programmatic guarantee that the provided SQL string is a SELECT statement and nothing more. In Python, I implemented that check using the pglast package for parsing the Abstract Syntax Tree (AST) of the SQL string, confirming that it contained a single statement, and confirming that the single statement is specifically a SELECT statement:

def validate_readonly_sql(sql: str) -> str: try: stmts = pglast.parse_sql(sql) except pglast.parser.ParseError as e: raise ValueError(f"SQL parse error: {e}") if len(stmts) != 1: raise ValueError("Only one statement is allowed") if (stmt_type := type(stmts[0].stmt).__name__) != "SelectStmt": raise ValueError(f"Only SELECT statements are allowed, got {stmt_type}") return sql

That will block the majority of destructive SQL calls, such as:

InputError
NOT VALID SQL!!!❌ SQL parse error: syntax error
SELECT 1; DELETE FROM observations❌ Only one statement is allowed
DELETE FROM observations❌ Only SELECT statements are allowed, got DeleteStmt

We're not safe yet! There are still a few tricky destructive SQL statements that can pass that check. We could extend the AST-based parsing to try to block those, but PostgreSQL offers a better way: read-only enforcement at the database level.

When we connect to the database, we run this SET command to enforce read-only transactions only:

SET default_transaction_read_only = ON

That blocks these CTEs that start with WITH and hide mutations inside:

InputError
WITH d as (DELETE ...) SELECT * FROM d❌ cannot execute DELETE in a read-only transaction
WITH u as (UPDATE ...) SELECT * FROM d❌ cannot execute UPDATE in a read-only transaction

We can go even further and create a dedicated PostgreSQL role for the MCP server that only has the ability to issue SELECT queries on a given schema:

CREATE ROLE mcp_readonly; GRANT CONNECT ON DATABASE bees TO mcp_readonly; GRANT USAGE ON SCHEMA public TO mcp_readonly; GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_readonly;

That role blocks these SELECT statements that call potentially destructive built-in SQL functions:

  • SELECT pg_terminate_backend(pid)
  • SELECT pg_read_file('/etc/passwd')
  • SELECT pg_reload_conf()

We could choose to enforce read-only access only via the least-privilege role, but that means your server has no layers of protection if that role isn't set properly for some reason. Just in case, it's best to employ all four layers of protection.

Could a malicious user or a capricious agent still find a way to slip a dangerous query through? If you need a 100% guarantee, the best option is to not expose SQL at all.

Templated query tools

In this approach, we define tools specific to common user needs, and those tools accept values that get safely merged into a templated SQL query - or passed to an ORM call.

For example, the search_species tool below accepts a search query string and an integer limit, and executes a templated SQL query on a hard-coded table:

@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) async def search_species(q: str, limit: int = 10) -> list[SpeciesResults]: """Search bee species by scientific or common name. Use to resolve a name to a taxon_id before calling other tools.""" sql = text(""" SELECT taxon_id, scientific_name, common_name, family, genus FROM species WHERE to_tsvector('simple', coalesce(scientific_name, '') || ' ' || coalesce(common_name, '')) @@ plainto_tsquery('simple', :q) ORDER BY scientific_name ASC LIMIT :limit""") async with engine.connect() as conn: result = await conn.execute(sql, {"q": q, "limit": min(limit, 50)}) return [SpeciesResult(...) for row in result.fetchall()]

We need to define additional tools for every SQL query that might be needed to answer user questions, like a search_observations_tool that accepts latitude, longitude, date, and species parameters.

When we provide GitHub Copilot with those tools and ask the question "Are there any carpenter bees around Berkeley?", the agent first calls search_species with a query of "carpenter bee" to get names and scientific metadata for matching bees, then calls search_observations with the latitude and longitude for Berkeley:

The obvious advantage of this approach is that the agent never writes the SQL statements themselves, so it can't accidentally issue a destructive, expensive, or slow query.

There's a massive drawback: the agent can only answer the subset of user questions that you've anticipated. If you decide to go with this approach, try to find a way to monitor which of your users' questions can't be answered, perhaps by exposing a give_feedback tool on the server that encourages feature requests.

Elicitation for destructive actions

If you are developing an MCP server that basically serves as an administration tool (versus a data analysis and exploration tool), then you likely do want to allow deletion - but with caution. In a database admin UI, a delete button is typically bright red and pops up a dialog to confirm before proceeding:

We can achieve a similar UI for our MCP server, thanks to form-based elicitation, a relatively recent addition to the MCP spec. In the MCP clients that support elicitations, the client will pop up a form with our desired question and options. We can then change what our tool does, depending on what the user selects.

For example, this delete_observation tool uses an elicitation to confirm the user really wants to delete the row that it found in the database:

@mcp.tool(annotations=ToolAnnotations(destructiveHint=True)) async def delete_observation(ctx: Context, observation_id: int) -> str: """Delete a bee observation.""" row = ... # look up the record result = await ctx.elicit( f"Permanently delete observation #{row.observation_id}?\n" f". {row.scientific_name} on {row.observed_data}\n", response_type=["yes, delete it", "no, keep it"]) if result.action == "cancel" or result.data == "no, keep it": return "Deletion cancelled." await session.execute( text("DELETE FROM observations WHERE observation_id = :oid"), {"oid": observation_id} ) await session.commit() return f"Deleted observation #{observation_id}"

When we ask GitHub Copilot to delete an observation, the agent runs that delete_observation tool and the elicitation dialog pops up. The user has to explicitly click to confirm deletion:

Elicitation is also useful beyond destructive operations. You can use it for resolving ambiguity in user queries ("Did you mean...?") or suggesting alternative queries when a request would be too expensive (like narrowing a 200 km search radius to 50 km).

Which approach should you use?

We've explored a spectrum of options for exposing your database as an MCP server:

Free-form SQL is a good fit for internal prototyping where you need maximum flexibility. Read-only SQL works well for data analytics use cases, to allow arbitrary analysis. Templated queries are the safest bet for production and user-facing scenarios. Across all approaches, always enforce DB-level permissions to reduce risk.

Building MCP servers for your database is a great way to empower users to interact with data through natural language, but you should design your tools with safety in mind.

To learn more, explore the complete source code on GitHub which contains four MCP servers demonstrating each of the techniques, and can be run either locally on on Azure.

Read the whole story
alvinashcraft
22 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

Get started with LiteRT on Raspberry Pi

1 Share

In this guide, our friends from Google walk you through how to install LiteRT and run high-performance on-device AI models using the LiteRT CLI tool. You can read more about the work Google has done to improve LiteRT performance on the Raspberry Pi platform here.

Step 1. Raspberry Pi OS Setup

This step is meant for those starting with a fresh Raspberry Pi device or those looking to install an updated version of Raspberry Pi OS.

  1. Download the Raspberry Pi Imager tool from raspberrypi.com/software.
  2. Launch the application.
  3. Select Raspberry Pi 5 in the ‘Choose device’ menu.
  4. Select Raspberry Pi OS (64-bit) under ‘Choose OS’.
  5. Insert your SD card and select it under the ‘Choose storage’ column.
  6. Enter a hostname (e.g. dino-pi), username (e.g. dino), and password (e.g. dinopi).

Note: Customize the hostname, username, and password to your preferences.

  1. Enable SSH.

Note: You may wish to skip enabling Raspberry Pi Connect.

  1. Click Save, then select Yes to begin the write process.
  2. Click Finish once writing and verification are complete.

Step 2. Hardware and connection

  1. Remove the SD card from your laptop and connect it via the SD card slot on your Raspberry Pi.
  2. Connect the Raspberry Pi and your laptop together via an Ethernet cable.

Note: You may need to use an adapter if your laptop does not have a matching port.

  1. Connect the Raspberry Pi to a power outlet using its power supply.

Step 3. Accessing the Raspberry Pi

  1. Open your terminal and ping your Raspberry Pi to confirm it is reachable on your network.

Note: Replace ‘dino-pi’ with the hostname chosen during installation (Step 1.6).

ping dino-pi.local
  1. SSH to the Raspberry Pi via SSH.

Note: When prompted, enter the password chosen during installation (Step 1.6).

ssh dino@dino-pi.local
  1. Verify the hardware architecture is aarch64.
uname -m

Step 4. Set up Hugging Face account and access

  1. If you don’t already have one, create an account on Hugging Face
  2. Go to your Hugging Face settings, click ‘Access Tokens’, then click ‘Create New Token’. This is referred to as <your_hugging_face_token_here> going forward.
  3. Follow the instructions for generating a token with the token type of Read.

Step 5. LiteRT CLI installation

  1. Update the system and refresh the available software packages.
sudo apt update

sudo apt full-upgrade -y
  1. Download and install the uv tool manager to handle your AI environments.
curl -LsSf https://astral.sh/uv/install.sh | sh
  1. Refresh your shell environment to ensure it recognizes the newly installed tools.
source $HOME/.local/bin/env
  1. Use the uv manager to install the LiteRT CLI, which can run and benchmark various models such as language models (via LiteRT-LM) or vision models.
uv venv --clear --python=3.13 --seed

source .venv/bin/activate

uv pip install litert-cli-nightly

Step 6.a. Run large language models 

  1. Navigate to the desired Gemma model (e.g. gemma-4-E2B-it-litert-lm).

Note: You may be required to accept the Gemma model terms for certain models.

  1. Export your token from Step 4 and run the litert lm command to start a conversation.
export HUGGING_FACE_HUB_TOKEN=<your_hugging_face_token_here>

litert lm run \

  --from-huggingface-repo=litert-community/gemma-4-E2B-it-litert-lm \

  gemma-4-E2B-it.litertlm \

  --prompt="What is the capital of France?"

Example output

dino@dino:~ $ litert lm run \

  --from-huggingface-repo=litert-community/gemma-4-E2B-it-litert-lm \

  gemma-4-E2B-it.litertlm \

  --prompt="What is the capital of France?"

Downloading gemma-4-E2B-it.litertlm from litert-community/gemma-4-E2B-it-litert-lm...

gemma-4-E2B-it.litertlm: 100%|██████████████████████| 2.59G/2.59G [02:06<00:00, 20.4MB/s]

The capital of France is **Paris**.

Step 6.b. Run classic machine learning models 

Find, download, and run the desired LiteRT model (e.g. efficientnet_b1). For example, you can download and run EfficicientNet for image classification:

litert download litert-community/efficientnet_b1 --output efficientnet

litert run efficientnet/efficientnet_b1.tflite --input <your-image.JPEG>

Example output

dino@dino:~ $ litert run efficientnet/efficientnet_b1.tflite --input 

shark.JPEG 

...

Outputs:

  linear (Top 5 Predictions):

    1: index 3 (tiger shark, Galeocerdo cuvieri) - score 7.4043

    2: index 2 (great white shark, white shark, man-eater, man-eating shark, Carcharodon carcharias) - score 4.7619

    3: index 4 (hammerhead, hammerhead shark) - score 3.3590

    4: index 395 (gar, garfish, garpike, billfish, Lepisosteus osseus) - score 1.7937

    5: index 0 (tench, Tinca tinca) - score 1.2683

Step 7. Run model with Raspberry Pi’s GPU (optional)

LiteRT uses WebGPU for GPU acceleration. It takes advantage of the Dawn WebGPU implementation, which can run on a Vulkan driver. Raspberry Pi 5 uses the V3DV open source Vulkan driver shipped with Mesa.

Experimental WebGPU support is available as of today. It can be enabled with the V3D_WEBGPU_OVERRIDE=1 environment variable using the updated official Raspberry Pi OS Mesa package.

WARNING: The GPU provides lower performance than the CPU. This is expected behavior, as WebGPU support is experimental in the V3DV Vulkan driver. Raspberry Pi’s CPU currently outperforms the GPU on this workload.

  1. Run LiteRT-LM using your Raspberry Pi’s GPU.
export V3D_WEBGPU_OVERRIDE=1

litert download litert-community/efficientnet_b1 --output efficientnet

litert run efficientnet/efficientnet_b1.tflite \

  --input <your-image.JPEG> \

  --gpu

Note: You need to set export V3D_WEBGPU_OVERRIDE=1 to activate GPU optimization.

What’s next?

We are excited to share that LiteRT integration and Gemma models are coming soon to Hailo AI accelerators! This update will allow you to seamlessly offload model inference to the Raspberry Pi AI HAT+ and AI HAT+ 2, delivering massive hardware acceleration benefits through the exact same LiteRT workflows you use today.

Explore our resources and start your journey with LiteRT:

We value your input. Please share your thoughts, feedback, or feature requests by opening an issue on our GitHub issue tracker. Share your cool Raspberry Pi + LiteRT + Gemma projects with @googlegemma. We can’t wait to see what you build!

Acknowledgements

Google: Changming Sun, Chintan Parikh, Cormac Brick, Daisuke Majima, Dillon Sharlet, Erin Walsh, Frank Barchard, Glenn Cameron, Ian Ballantyne, Jingjiang Li, Jun Jiang, Kimish Patel, Lu Wang, Matthias Grundmann, Rodney Witcher, Sachin Kotwani, Sasha Denisov, Scott Loftin, Shuangfeng Li, Somdatta Banerjee, Terry (Woncheol) Heo, Volodymyr Kysenko, Weiyi Wang, Yi-Chun Kuo, Yu-hui Chen, and the gTech team

Raspberry Pi: Ashley Whittaker, Naushir Patuck, and Sarah Cunningham

Igalia: José María Casanova

Hailo: Eldad Rubinstein

Ultralytics: Francesco Mattioli, Lakshantha Dissanayake, and Onuralp Sezer

Moonshine AI: Pete Warden

The post Get started with LiteRT on Raspberry Pi appeared first on Raspberry Pi.

Read the whole story
alvinashcraft
23 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

How kids feel about AI, in their own words

1 Share

When we set out to talk to kids about artificial intelligence, we thought we knew what we’d hear. We expected some to tell us they were using it to cheat a little, the way Millennials and Gen Xers opened up CliffsNotes or programmed formulas into their TI-82s, and others to share inspiring ways they were using it. We were also listening for concerns that were less kid-specific, like deepfakes or job destruction. But what we actually heard when we asked kids aged 10 to 18 about AI had tons of nuance. 

Many of the same kids who can go on and on about music, rock climbing, or soccer met our questions with words like “bruh” and “meh”—or were so deeply against AI or uninterested in making it part of their lives that they didn’t want to talk about it at all. One teen said his peers use it for things they know they shouldn’t, like writing papers. One told us she won’t touch AI because of the environmental impact. A few said they find the whole field disheartening: “AI isn’t the solution to our problems,” said Winter, a 17-year-old. “I’m afraid it’s going to be the end of creativity and critical thinking.” Yet most of the kids we asked admitted to using AI at least a little bit.

AI doesn’t yet seem to be something a lot of elementary- or middle-school-age kids we spoke to are focused on—and they aren’t begging for it, the way they do for iPhones and Snapchat accounts. Many told us that some of their first AI encounters came from their parents or schools. Sometimes, they said, it’s just embedded in the devices and apps they already rely on. It’s just there, in things like a Google search. 

What we heard tracks with the data. In a survey published in February 2026, the Pew Research Center found that 57% of teens in the US had used chatbots to search for information, 54% tapped them to help with schoolwork, and 47% had used them for fun or entertainment. Only 12% had used them for emotional support or advice. Teens are over four times more likely to be using AI in innocuous ways than potentially harmful ones. Some are even using it to build things, whether it’s a character, a tech platform, or a tutor to help other kids study.

None of that means the worries are misplaced. Kids can stumble into unfiltered content, lean on a chatbot instead of their own judgment, or trust an answer that’s wrong—and they should be protected from those dangers. But the danger is the reason to teach the thing, not to avoid it. We don’t teach teens to never drive. We teach them to check their blind spots.

What surprised us most was how much young people might be able to teach adults about AI, and how clearly the kids who use it could name what they will and won’t hand over. They’re not as worried that it will take their jobs as they are that it might impact society. And, with increasing access to tools that could in theory do their thinking, their talking, or even their friend-­making for them, it sounds as if most want to keep their hands on the wheel.

Interviews have been edited for length and clarity.

JUSTYNA STASIK

The Coder

Remy, 16, New York

The word that comes to mind when I think about AI is “indifferent.” I just don’t find the current applications that exciting for my own use. I go to school. I teach tae kwon do. I read. I play games with friends. None of that needs AI. I mean, I use it. I mostly use Claude, the free version, for programming outside of school. I had it help me write a program to see if I could tweak my computer’s overclock. So I see the appeal. 

But at school, I actually think AI mostly makes my assignments worse, not better. In English, everything is now in-class writing, because teachers don’t want kids cheating. So we have only 70 minutes to write a whole essay, and I think that hinders my writing. (Did you know Princeton voted to let faculty proctor exams for the first time in over a century? Their honor code goes back to 1893, and now it’s over because of AI.)

As far as code goes, I’d also rather build things myself. I’ve been making a reinforcement-learning model in a game engine with a friend; it moves randomly at first, gets rewarded for walking toward a coin, and after enough iterations it teaches itself the most efficient path. I’ve also tested AI for game development, and it isn’t there. It makes sloppy code, and it’s bad at blending mechanics into something cohesive. I’d spend more time correcting it than writing it myself.

I think AI right now is sort of like the first car or the first airplane. It’s interesting but crude. It’s obviously an amazing invention but not actually good yet.

Overall, I think AI right now is sort of like the first car or the first airplane. It’s interesting but crude. It’s obviously an amazing invention but not actually good yet. It’ll get somewhere. One thing I read about was AI flagging breast cancer more accurately, trained to catch its own false positives so a human still verifies. That’s the version I care about.


The Organizer

Danielle, 18, California

I’m studying engineering, and my life goal is to innovate technology that will help as many people as I can. The way I see it, AI isn’t inherently good or bad; that’s decided by the people using it. It’s already being used for lots of good. Just think about how it helps people with personalized education and more accessible medical diagnoses.

PING ZHU

So far, the biggest project I’ve worked on with AI is called Next Voters. My teammates are more on the technical side, and I’m working on scaling. Right now, we’re focusing mostly on city councils as well as states. Our system turns dense, hundred-page documents into a headline and a few plain-language bullet points in your inbox. And everything is cited, so you can click straight to the actual policy to learn more. The information comes to you, instead of you having to remember to go search or prompt for it. 

One AI agent finds official government sources for a given city or statethe council website, the proposed bills, the meeting transcripts. Another verifies they’re real and credible; another scrapes them every week for the latest updates; another sorts them into categories like civil rights, immigration, and economics; and the last one writes our weekly newsletter.

The project’s goal is to reduce the barriers to democratic participationto make sure anyone, regardless of race, gender, income, or education level, has an easy way to get the information they need and then think critically about how they want to use it. We made it because right now, it feels as if most teens aren’t very engaged. I was in English class when the war in Ukraine came up and someone said, “There’s a war going on?” That gap, plus all the emotionally charged social media misinformation that gets promoted because it earns the most clicks, makes me nervous for the next generation of voters.

We don’t want AI to think for people; we want to use it to disperse knowledge. In other words, we want to deal people the cards and let them play them however they want, but we have to make sure they have the cards in the first place. 


The Cringe-o-meter

We asked kids to rate a range of AI uses from totally fine to not okay.


CHRIS PIASCIK

The Naturalist

Hazel, 17, New York

When ChatGPT first came out, my dad showed it to me and it seemed fun. But as it got more prominent and seemed to be everywhere, I started to feel uneasy. Then I learned about the environmental impact.

I’m a rock climber and I hike a lot. It’s good because when I’m on a wall, I’m just focused on staying on that wall. I’m not thinking about my phone or anything else. That’s why I love it. I also love the views and being around animalseven insects. I want to be an ecologist, and the more time I spend in nature, the more I want to protect those wild spaces.

The part that bothers me most about AI is the data centers that companies are building to enable it. They house these huge blocks of servers that use enormous amounts of water. They take it from local towns and don’t leave enough behind for the people who actually live there. And when they get big enough, they put off so much heat they can raise the local temperature a degree or two.

So I make small choices. When AI pops up somewhere, I just don’t engage with it. It can feel isolating when everyone around me is using it, but I don’t want AI to be the thing that kills the places I love.

""
PING ZHU

The Storyteller

Wesley, 14, Ohio

My friends and I have all heard about AI and seen videos made by AI, but I mostly use it for school. I wrote a short story and ran it through ChatGPT to catch my grammar and spelling errors, and I used it to debug a little game I’d coded for a project. What I worry about is it robbing us of our ability to think creatively, or to think for ourselves.

But I have tried using AI for fun. When I was bored, I tried to have a conversation with ChatGPT once or twice, but I didn’t really like it. Character.AI is more fun. You type in all this information, give it a bunch of prompts and a profile picture, and then you can post your AI character for anyone to use. You just put what you’ve made out there. Then you talk to it. My favorite show is One Piece on Netflix, so I threw myself onto its pirate crew using a character I found. 

Other people have used Character.AI to build whole games. There’s a rap-star simulator where you pick your difficulty and where you’re from, and the AI creates a game out of that. There are also World War II simulators, and chats where you’re working with assassins from a TV show. You can find pretty much anything.

I guess I’d recommend it, but with caution. The content is pretty unfiltered, so you have to be careful what you click on. You learn its limits fast, too. On the free version the memory runs out: Get far enough into a chat and it slows down and forgets what happened. It’s like everything else with AI. If you trust it to run on its own, it falls apart. You have to keep steering it where you want it to go. 

I guess I’d recommend it, but with caution. The content is pretty unfiltered, so you have to be careful what you click on. You learn its limits fast, too.

JUSTYNA STASIK

The Artist

Sylvia, 10, Michigan

I haven’t used tools like ChatGPT or Claude myself, but my mom does. I really like to draw and write songs, but I don’t use AI for that. I don’t really have big feelings about AI either way. It’s a little like a calculator. A calculator does the math for you, and AI does other things for you. But I don’t like when AI tricks you, like when my mom found some songs she liked on Spotify and then looked up the artist to see what they looked like. It turns out the whole thing was made by AI. I was surprised, even though I still like the song.

I do use AI at school, through a program called SchoolAI. Mostly I put my writing in and it gives me ideas or helps me revise. You can’t have it just write for you, but you can use it to help. When I’m older I want to be an artist, or maybe a librarian. I’d probably use some technology either way. But the drawing and the songwriting? Those I want to keep doing myself. 


The Cringe-o-meter (continued)

We asked kids to rate a range of AI uses from totally fine to not okay.


PING ZHU

The Pre-Premed

Evelyn, 13, Oregon

In January, I was diagnosed with type 1 diabetes, and that’s when AI became a bigger part of my life. Now when we’re cooking, we can run a recipe through ChatGPT, tell it the serving size, and it works out how many carbs there are. We use AI like that a lot.

My glucose monitor and my insulin pump also talk to each other using their own kind of AI to predict dosing. The monitor tracks what my blood sugar actually is, and the pump does the math. So if it predicts that my blood sugar will be high in 30 minutes, it gives me a correction dose, and if it predicts I’m about to go low, it stops the insulin before that happens. When I was first diagnosed I was still doing shots, and I went low almost every night. It was really stressful. Now the pump can catch it, and at night my phone goes off if I drop, so I wake up and drink juice. Mostly, I just get to sleep more because of it.

But the hardest part of having diabetes isn’t something I can use AI for. It’s remembering to carry all my supplies everywhere—to school, to a long day of anything.

I do use AI for school sometimes. Memory tricks when I’m studying for a test, ideas to get a project started. It’s a really good tool for that. But I don’t know exactly how I’ll use AI in the future. I want to be an endocrinologist someday, so I figure something will come up, since I’m already using it to help with my diabetes. I know other people worry that AI is going to take over the world. I don’t really think so. I still think we’re in control of it, and I think the benefits outweigh the risks.

I don’t know how exactly I’ll use AI in the future. I want to be an endocrinologist someday, so I figure something will come up, since I’m already using it to help with my diabetes.


The Inventor

Krishiv, 17, Ontario, Canada

When I was growing up, I always liked building things: Lego builds, Minecraft worlds, and then video games in Scratch. I’d make a little game, post it for other kids to play, read the comments, and make it better. Then, when I started high school, I had to spend way more time studying than I ever had, and honestly I just wanted to build things. So I went looking for ways to get good grades while studying less. Khan Academy had an AI tutor in the works, but it was stuck behind a waitlist, so I figured, why not build my own? 

After months of launching random stuff, I created an AI tutor called Aceflow. You could feed it anything a teacher assigneda 30-minute lecture video on YouTube, a blog post, a PDF of the textbook or presentation slidesand it would spin up endless practice questions, with a tutor on the side that explained things the way my teacher did. I built it just for myself, showed it to my friends, then put it on TikTok. It got tons of views on TikTok and thousands of users.

JUSTYNA STASIK

Was I worried people would call it cheating? Not really. I knew how to defend it: A tool like this isn’t so different from well-off families hiring expensive private tutors, except everyone gets one. That part mattered to me. Back in eighth grade, a teacher had me run a little computer science class for about 30 kids with special needs, and once they got personalized attention, they were building games nobody expected of them. That convinced me that kids are capable of so much more than people think, and AI can help scale that level of personalized attention to everyone. That unlocks so much potential.

That first AI tutoring project ended up helping me land part-time roles at BenchSci (one of Canada’s biggest AI companies) and Simple Ventures (a venture firm). More recently, I joined an AI lab at MIT; co-instructed an AI agents course with an MIT professor; and launched CheetahPrep.com, an SAT prep platform that uses AI to adapt to each student.

I’m generally optimistic about how AI will impact humanity, but when other kids’ first reaction is fear, I think that’s an important sign too. It’s a reminder that we should be excited about the future while still being mindful of the risks, working together to make AI work for humanity.


Jen Swetzoff and Keeley McNamara are the founding editors of Anyway, an independent print magazine for tweens and teens.

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