all posts

How to Give a Semantic Kernel Agent a Code Execution Tool

Ajay Kumar··9 min read

Semantic Kernel is where a large share of enterprise agent work actually happens. Not because it wins framework beauty contests, but because it fits: it treats .NET as a first-class citizen, it drops into an ASP.NET service or an Azure Function without an argument, and it comes from the same vendor that already supplies the identity, the model endpoint and half the data estate. If the company runs on Microsoft, SK is the path of least resistance, and paths of least resistance are how architecture actually gets decided.

I'm Ajay; I build PandaStack, a Firecracker microVM platform, and the SK conversations I get pulled into are almost never about the framework. They are about the process it is running inside. Because the moment you add a plugin that executes model-written code, the interesting question stops being "how do I register this" and becomes "what does this process already hold keys to."

The SK agent is usually sitting inside something valuable

Here is the risk, stated once and plainly. A typical Semantic Kernel host is a long-lived service in a corporate subscription. It runs as a managed identity. It can mint a Graph token. It has a connection pool already open to a production database, with whatever permissions the app was granted three re-orgs ago. It sits inside a VNet with private endpoints to storage accounts and internal APIs, it can reach the instance metadata endpoint, and it very likely has certificate or key material mounted into its filesystem by a secrets provider. Every one of those is a legitimate, deliberate design choice for an enterprise application.

A code-execution plugin that runs in that process inherits all of it. Not some of it — all of it, because in-process code inherits the process. The plugin does not need to be malicious or even buggy; it just needs to do exactly what it says on the tin, which is run the code it was handed. This is not a criticism of SK. It is the consequence of SK being good at the thing it is for: living close to your real systems.

Automatic function calling means you are not in the loop

The whole point of registering a plugin is that you stop calling it. You configure the chat-completion service to invoke functions automatically, the model reads your function descriptions, and it decides when a call is warranted. That is the feature. It is also the part people mentally skip when they reason about the blast radius, because the code they wrote contains no line that calls the plugin, and it is hard to feel responsible for a call you cannot find in your own source.

So put it together. We have delegated the decision to execute arbitrary Python to a statistical text model, which will form that decision after reading a support ticket, a scraped web page, or a PDF that a stranger emailed to a shared inbox. We have placed that execution in the same process as a credential the finance system trusts. And then we shipped it, because the demo was genuinely impressive and the sprint was ending. Written out in full it has a certain honesty to it.

The tempting version looks like this, and it takes about twelve lines.

from semantic_kernel.functions import kernel_function


class DangerousCodePlugin:
    @kernel_function(
        name="run_python",
        description="Run a Python snippet and return whatever it prints.",
    )
    def run_python(self, code: str) -> str:
        import io, contextlib

        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            exec(code, {})          # <- the entire subject of this post
        return buf.getvalue()


# What that exec() actually grants, inside a normal enterprise SK host:
#   * every environment variable in the process, connection strings included
#   * the managed identity the app runs as, and every token it can mint --
#     Graph, Key Vault, Storage, your own downstream services
#   * the already-open database pool, with the application's own privileges
#   * the pod's network position: private endpoints, VNet peers, metadata
#   * the filesystem, including any mounted certificates or private keys
#
# The model chooses when to call this, after reading text you did not write.
# The `{}` globals argument is not a sandbox; it is a namespace.
Passing an empty globals dict to exec() restricts nothing meaningful. Python object graphs are reachable from almost any starting point, and the standard bypasses are a search away. Treat any in-process interpreter restriction as a lint rule, not a boundary.

The good shape: a plugin that forwards to a sandbox

The fix is not to make the plugin cleverer. It is to move the execution somewhere with its own kernel, and leave a thin forwarding function behind. From SK's point of view nothing changes: it is still a class with kernel functions, still registered with add_plugin, still auto-invoked. From the model's point of view nothing changes either. What changes is that the code now runs in a Firecracker microVM with its own kernel, its own filesystem and no relationship whatsoever to your managed identity.

One instance per conversation, holding a persistent code context so that state survives from one invocation to the next. That last part matters more than it sounds: an agent that loses its variables between calls has to rebuild them every turn, which burns tokens and produces worse plans.

from typing import Annotated

from pandastack import Sandbox
from semantic_kernel.functions import kernel_function

MAX_OUTPUT = 6000


class CodeExecutionPlugin:
    """Executes model-written Python inside a Firecracker microVM.

    One instance per conversation. The sandbox holds a persistent code
    context, so variables, imports and files survive across invocations the
    way a notebook kernel does.
    """

    def __init__(self, ttl_seconds: int = 1800) -> None:
        self._sbx = Sandbox.create(template="code-interpreter",
                                   ttl_seconds=ttl_seconds)
        self._ctx = self._sbx.create_code_context(language="python")

    @kernel_function(
        name="run_python",
        description=(
            "Execute a Python 3 snippet in an isolated sandbox and return its "
            "stdout and stderr. Variables, imports and files persist between "
            "calls within this conversation. There is no network access. Use "
            "print() to return values -- the snippet's own return value is not "
            "captured. Use for arithmetic, data analysis, and file processing."
        ),
    )
    def run_python(
        self,
        code: Annotated[str, "Python source to execute. Use print() for output."],
    ) -> Annotated[str, "Combined stdout and stderr, truncated."]:
        try:
            ex = self._ctx.run_code(code, timeout_seconds=60)
        except Exception as err:
            # Errors come back as text the model can read and react to.
            # Raising here would surface as an opaque plugin failure instead.
            return f"EXECUTION_ERROR: {err}"

        parts = [ex.logs.get("stdout", ""), ex.logs.get("stderr", "")]
        out = "\n".join(p for p in parts if p) or "(no output)"
        if len(out) > MAX_OUTPUT:
            out = out[:MAX_OUTPUT] + "\n...[truncated -- print less, or aggregate]"
        return out

    @kernel_function(
        name="write_file",
        description=(
            "Write a UTF-8 text file into the sandbox under /work so a later "
            "run_python call can read it. Overwrites any existing file."
        ),
    )
    def write_file(
        self,
        path: Annotated[str, "Absolute path under /work, e.g. /work/data.csv"],
        content: Annotated[str, "The file contents."],
    ) -> str:
        self._sbx.filesystem.write(path, content)
        return f"wrote {path}"

    def close(self) -> None:
        self._sbx.kill()

Three details are doing real work there. Errors are returned as text rather than raised, because a model that receives "EXECUTION_ERROR: NameError: name 'df' is not defined" will fix its own code, whereas an exception propagating out of the plugin becomes an opaque failure the agent cannot reason about. Output is truncated before it reaches the model, because the first thing any code agent eventually does is print an entire dataframe and turn your context window into a CSV. And the sandbox is owned by the plugin instance, so its lifetime is something you control rather than something you hope about.

The description string is a correctness control, not documentation

In SK, the description you pass to kernel_function is not a comment. It is the schema the model reads when deciding whether to invoke your function and what to pass it. Nobody else ever reads it. If the description is vague, the model's invocation behaviour is vague, and you will debug that as a model problem when it is a writing problem.

  • Scope — Weak: "Runs code." Strong: "Execute a Python 3 snippet in an isolated sandbox and return its stdout." The weak version gets called for questions the model should have answered directly.
  • Statefulness — Weak: silent. Strong: "Variables, imports and files persist between calls within this conversation." Without this, the model re-imports pandas and reloads the CSV on every single call.
  • Output contract — Weak: silent. Strong: "Use print() to return values; the snippet's own return value is not captured." This one line eliminates the most common failure mode, where the model writes a bare expression and gets back an empty string.
  • Constraints — Weak: silent. Strong: "There is no network access." Stating the limit stops the model from writing a requests call, watching it fail, and then trying again with urllib.
  • Parameters — Weak: an unannotated `code: str`. Strong: an Annotated type carrying its own description, so the argument's meaning travels into the schema rather than relying on the parameter name.

Write them as if the reader is a competent contractor who has never seen your system and will not ask a follow-up question. That is very close to literally true.

Wiring it into the Kernel

Semantic Kernel iterates quickly, and it has reorganised its packages more than once. Import paths and the exact name of the function-choice / automatic-invocation setting differ between versions and between the .NET and Python flavours — check the current SK documentation for the version you have installed rather than copying the two lines below verbatim. The load-bearing part of this post is the plugin class above; the wiring is whatever your SK version calls it this quarter.
import asyncio

from semantic_kernel import Kernel


async def answer(question: str) -> str:
    kernel = Kernel()
    kernel.add_service(chat_service)          # your Azure OpenAI / OpenAI service

    # One microVM for this conversation. Nothing from os.environ is forwarded
    # into the guest -- if the task needs a token, pass that one token in.
    tools = CodeExecutionPlugin(ttl_seconds=1800)
    kernel.add_plugin(tools, plugin_name="code")

    settings = kernel.get_prompt_execution_settings_from_service_id("chat")
    # Automatic function calling: from here on, the model decides when
    # code.run_python gets invoked. Confirm the current spelling of this
    # setting against the docs for your SK version.
    settings.function_choice_behavior = auto_function_choice()

    try:
        result = await kernel.invoke_prompt(
            question,
            arguments=KernelArguments(settings=settings),
        )
        return str(result)
    finally:
        # The VM dies with the conversation, not with the idle reaper.
        tools.close()


asyncio.run(answer("Load /work/sales.csv and give me the top 3 regions by revenue."))

The finally block is not decoration. Sandbox lifetime should be tied to something you can name — a conversation, a request, a job — and released deterministically. Platform-side TTLs exist to catch the cases where your process died, not to be your primary cleanup strategy. If your only mechanism for destroying sandboxes is a timeout, you will eventually discover how many conversations your users abandon.

Filters are where policy belongs

SK has a function-invocation filter concept: a hook that wraps every function call the kernel makes, seeing the arguments on the way in and the result on the way out. The exact registration API has moved around, so check your version's docs, but the concept is stable and it is the right home for everything you would otherwise be tempted to bolt into the plugin body.

  • Audit logging. Every invocation of a code-execution function, with the code, the caller's identity and the sandbox id, into the same log stream your security team already reads. If someone asks 'what did the agent run last Tuesday', you want an answer that is not a shrug.
  • Wall-clock budgets. Per-call timeouts belong in the sandbox call, but a per-conversation ceiling — total executions, total seconds — belongs in the filter, where it can see every call rather than one.
  • Per-call policy. Whether this user, in this tenant, on this plan, is allowed to trigger code execution at all. That decision is authorisation, and authorisation does not belong in a prompt where the model can be talked out of it.
  • Result shaping. Truncation and redaction applied uniformly across every plugin, so a new plugin added next quarter inherits the behaviour instead of forgetting it.

The unglamorous rules

Most of the distance between a convincing demo and something you would put in front of an enterprise security review is covered by a handful of unexciting decisions.

  • Do not forward your environment. It is tempting to pass os.environ through to the guest so that "things just work". That single convenience re-creates the exact problem you moved execution out of process to solve. Pass the one credential the task genuinely needs, scoped and short-lived, or pass nothing.
  • Network off by default. Most analysis tasks need no egress at all. Turn it on for the tasks that do, allow-listed, and treat that as a per-task decision rather than a platform default.
  • Pre-install the libraries. Bake pandas, numpy and whatever else the agent reaches for into the template instead of letting it pip install mid-conversation. Installs are slow, they fail in ways models handle badly, and a pre-warmed environment makes runs reproducible.
  • Truncate, and say so. Cut output at a fixed budget and append a marker telling the model it was cut, so it aggregates instead of retrying the same enormous print.
  • One sandbox per conversation, never one shared across users. The shared instance always starts life as a module-level global that seemed harmless in development and became multi-tenant in production.
  • Timeouts everywhere. Per execution, per conversation, and a TTL on the sandbox itself as the backstop.

The cost objection, with actual numbers

Someone will ask whether a whole VM per conversation is extravagant. It is a fair question and it deserves numbers rather than an opinion. On PandaStack there is no warm pool of idle VMs — every create restores a baked Firecracker snapshot, which lands around 179ms at p50 and 203ms at p99. That is a fraction of the time the model spends composing its first token, so a sandbox per conversation is not a latency story. First-ever boot of a template, before a snapshot exists, is around 3 seconds, and you pay it once.

Billing is per second, at $0.054 per active vCPU-hour and $0.0162 per GiB-hour, which means a sandbox sitting idle while the model thinks costs approximately nothing. That is the shape that matters for agents, because agents are mostly waiting. Against the token bill for a single multi-step run, the compute is a rounding error — which makes "we ran it in-process to save money" a difficult position to defend in front of the people who priced the incident.

The honest summary: Semantic Kernel gives you a clean way to expose a capability to a model, and the model will use it more autonomously than your intuition suggests. Keep the plugin thin, keep the description precise, put the policy in a filter, and put the execution behind a kernel boundary that your managed identity has never heard of.

Frequently asked questions

Does this apply to Semantic Kernel for .NET, or only Python?

The boundary argument is identical, because it has nothing to do with the language of your host application. The problem is that in-process execution inherits the process, and a .NET process holding a DefaultAzureCredential and an EF Core pool is exactly as attractive a target as a Python one — arguably more so, since it is more likely to be the production service. The sandbox is a remote service you call over HTTP, so from .NET you write a [KernelFunction] with a description, and inside it you make an HTTP call to the sandbox API instead of calling the Python SDK. The plugin shape, the description discipline, the filter for policy and the per-conversation lifetime all transfer unchanged.

Can I just restrict what the model is allowed to execute with a prompt?

No, and it is worth being blunt about why. A prompt instruction is a preference expressed to the same component that is being manipulated by the untrusted input; it is not an access control. Anything that reaches the model's context can argue with it, and prompt injection research has spent years demonstrating that these arguments frequently win. Put the constraint somewhere the model cannot address: a different kernel, a network policy, an invocation filter that checks the caller's identity.

How do I keep state between plugin invocations?

Hold a persistent code context inside the sandbox and give the plugin instance a lifetime that matches the conversation. Each kernel function call then runs against a live interpreter, so variables, imports and files written in one call are still there in the next, exactly as they would be in-process. This is also why one plugin instance per conversation is the right granularity: state should survive across the steps of one task and must not survive into somebody else's.

What should the function description actually say?

State what the function does, what it returns, whether state persists between calls, and what it cannot do. The model reads that string as its schema, so anything you leave out becomes a guess. Explicitly saying that output is captured from print(), that there is no network access, and that variables persist between calls will remove the majority of the wasted invocations you would otherwise spend a week debugging as model unreliability.

Where do timeouts and audit logging belong?

Per-execution timeouts go on the sandbox call itself, so a runaway loop is bounded at the source. Everything cross-cutting — audit logging, per-conversation execution budgets, authorisation checks on whether this caller may run code at all — belongs in SK's function-invocation filter, which sees every call the kernel makes. Putting it there rather than in each plugin means the policy applies automatically to the next plugin someone adds, which is the one that will forget.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.