name: agentic-programming description: "Write, edit, validate, save, and run Agentic Programming functions (@agentic_function) directly with your own file-editing tools. Covers next-step decision making — letting the LLM pick the next function/value via decision.make or runtime.exec(choices=). No dedicated meta functions — just follow the rules in this skill. Triggers: 'write an agentic_function', 'create a function', 'edit a function', 'improve a function', 'fix this function', 'add a function that', 'add a tool that', 'run a function', 'let the model decide', 'make the model pick', 'decision.make', 'next-step decision'."
Agentic Programming — author, edit, validate, save
This skill is a complete guide for writing and maintaining @agentic_function-style functions in this repo. You (the agent) read this, then use your own Read / Write / Edit / Bash tools to make the changes directly. There are no dedicated create() / edit() / improve() framework functions — they were removed because all they did was wrap a single LLM call plus file write, which you can do yourself.
When to invoke
Use this skill when the user asks to:
- write a new function (agentic or pure-Python) in this project
- edit, fix, improve, refactor an existing function in this project
- generate a
SKILL.mdfor an existing function
Do not invoke this skill for unrelated work (downloads, web search, system commands).
The workflow
1. Pick the target file
2. Decide: agentic_function or plain Python?
3. Draft the code following the spec below
4. Self-validate against the rule checklist
5. Write the file (Write tool for new file, Edit for modify)
6. (Optional) Run a smoke test by importing + calling
That's it. Every step is something you do with your normal tools.
1. Picking the target file
| Situation | Where the file goes |
|---|---|
| User said "save to X" | Exactly X. |
| Brand new general-purpose function | openprogram/functions/agentics/<name>/__init__.py |
| Editing an existing function | The file you found it in (don't move it). |
| User's project / non-framework function | Wherever fits their layout (ask if unclear). |
Directory + filename convention: lowercase snake_case folder matching the function name (e.g. analyze_sentiment/__init__.py contains def analyze_sentiment). The folder layout (one directory per agentic function, code in __init__.py) replaced the old flat <name>.py layout in the function-calling unification — see docs/design/function/function-calling-unification.md. Single-file helpers inside the same logical agentic can sit next to __init__.py (e.g. analyze_sentiment/_prompt.py) without polluting the top-level namespace.
2. agentic_function vs plain Python vs @function
| What you're building | Decorator | Where it lives |
|---|---|---|
| LLM-reasoning logic (analyze / classify / generate / decide) | @agentic_function + runtime: Runtime + runtime.exec(content=[...]) |
openprogram/functions/agentics/<name>/__init__.py |
| Deterministic helper (parsing / math / file munging / API wrapper) | plain function, no decorator, no runtime parameter |
wherever it's used; if shared, agentics/_utils/-style |
| Framework-level deterministic LLM tool (bash / read / web_search / etc.) | @function (different decorator!) |
openprogram/functions/tools/<name>/ |
This skill is about @agentic_function. The @function decorator is a different mechanism for framework-level leaf tools and is out of scope here — see docs/design/function/function-calling-unification.md if you need it. Both decorators ultimately produce AgentTool entries in the same shared registry, but they target different kinds of work: @function for deterministic Python tools called by the LLM, @agentic_function for higher-order functions whose body itself drives an LLM round.
Don't decorate a function just to "make it discoverable"; @agentic_function implies an LLM call inside the body.
3. Function metadata specification
The framework's components (WebUI, catalog menus, provider-native tools=[...]) all read metadata from the same places. Use these as the only sources:
| Information | Where it lives | How it's read |
|---|---|---|
| Function name | def <name>(...) |
fn.__name__ |
| Parameter names | signature | inspect.signature(fn).parameters |
| Parameter types | annotation | param.annotation |
| Parameter defaults | annotation default | param.default |
| One-line summary (what / when-to-pick) | first paragraph of docstring | first paragraph of inspect.getdoc(fn) |
| Detailed function-level documentation | docstring body | rest of inspect.getdoc(fn) |
| Per-call LLM prompt + data | the content=[...] of that specific runtime.exec call |
passed at call time |
| Per-parameter description | @agentic_function(input={"x": {"description": ...}}) |
fn.input_meta["x"]["description"] |
| Per-parameter enum | @agentic_function(input={"x": {"options": [...]}}) |
fn.input_meta["x"]["options"] |
| Hidden-from-LLM parameter | @agentic_function(input={"x": {"hidden": True}}) |
fn.input_meta["x"]["hidden"] |
| WebUI placeholder | @agentic_function(input={"x": {"placeholder": "..."}}) |
fn.input_meta["x"]["placeholder"] |
| WebUI multiline input | @agentic_function(input={"x": {"multiline": True}}) |
fn.input_meta["x"]["multiline"] |
| Dynamic option source | @agentic_function(input={"x": {"options_from": "functions"}}) |
fn.input_meta["x"]["options_from"] |
| Working-directory picker mode | @agentic_function(workdir_mode="optional"|"hidden"|"required") |
AST-parsed from the decorator's source text by the WebUI (openprogram/webui/_functions.py), so write it as a literal in the decorator call |
| Auto-injected by the framework | param name in {"runtime", "exec_runtime", "review_runtime"} |
framework checks signature |
| System-prompt override | @agentic_function(system="...") |
fn.system |
| Context-tree visibility | @agentic_function(expose="io"|"full"|"hidden") |
fn.expose |
| Context-tree render range | @agentic_function(render_range={"callers": ..., "subcalls": ...}) |
fn.render_range |
| Skill trigger keywords | sibling SKILL.md frontmatter |
skill loader |
Single source of truth: anything expressible in the signature / annotations is not repeated in the decorator; anything expressible in input= is not repeated in the docstring.
4. The docstring vs content split
These two channels have different responsibilities. Neither replaces the other.
| Channel | Scope | What goes here |
|---|---|---|
| docstring | Whole-function level. Read by humans, catalog menus, tool_use specs. | One-line summary (required). Optionally a body describing what each LLM call does, expected outputs, edge cases. As detailed as is useful for readers. |
runtime.exec(content=[...]) |
One specific LLM call inside the function. A function may make several with different prompts. | The actual prompt + data for this call: the task, output format, constraints, plus the data to operate on. Required even if the docstring already explains the same thing. |
The docstring reaches the model — but as description, not as the operative instruction. The framework stores the docstring on the function's DAG node and renders it into the context of the LLM calls made inside the function; it also becomes the tool description when the function is exposed via tools=[fn]. So the model sees it. But it arrives as descriptive context, and some providers (codex CLI / chatgpt subscription) respond conversationally to whatever is in content, treating the rest as background. The operative per-call prompt — task, output format, constraints — must therefore live in content. Rule of thumb: the docstring describes what the function is; content instructs the call. Write the per-call prompt in content even if the docstring already explains the same thing.
5. Recommended style (new code must use this)
Minimal example:
from openprogram.agentic_programming.function import agentic_function
from openprogram.agentic_programming.runtime import Runtime
@agentic_function(input={
"text": {"description": "Text to analyze."},
})
def analyze_sentiment(text: str, runtime: Runtime = None) -> str:
"""Classify the sentiment of a text into positive, negative, or neutral."""
reply = runtime.exec(content=[{"type": "text", "text": (
f"Classify the sentiment of the following text. Reply with exactly "
f"one word: positive, negative, or neutral.\n\n"
f"Text:\n{text}"
)}])
label = str(reply).strip().lower()
return label if label in {"positive", "negative", "neutral"} else "neutral"
Fuller example exercising more metadata features:
from openprogram.agentic_programming.function import agentic_function
from openprogram.agentic_programming.runtime import Runtime
@agentic_function(input={
"essay": {
"description": "Essay to review.",
"placeholder": "Paste the essay text here...",
"multiline": True,
},
"rubric_id": {
"description": "Which rubric to apply.",
"options": ["ielts_writing", "toefl_writing", "gre_argument"],
},
"max_score": {
"description": "Upper bound for the numeric score.",
},
"show_rubric_internals": {
"description": "Include rubric breakdown in the output.",
},
"session_id": {
# System-supplied; LLM does not see this.
"hidden": True,
},
})
def review_essay(
essay: str,
rubric_id: str,
max_score: int,
show_rubric_internals: bool,
session_id: str, # filled by Python via context, not LLM
runtime: Runtime = None, # auto-injected
) -> dict:
"""Score an essay against a named rubric and return a structured report."""
# ... real implementation here ...
The docstring stays one line + maybe a body paragraph. No Args: or Returns: sections. If the return shape matters to downstream callers, encode it with a structured return type (TypedDict / dataclass).
5b. Next-step decision making
When a function needs the LLM to decide what to do next — pick one of several follow-up functions or values — do not hand-roll it (render a menu, parse JSON, branch on the result). Use the framework's decision primitive. Two entry points, same options, same resolution:
| Entry | Use when |
|---|---|
decision.make(prompt, options) |
Pure decision — the model picks straight away, no work first. |
runtime.exec(..., choices=options) |
The model does a full turn (reasoning, tool calls) and only the finish is the pick. |
The function still declares runtime: Runtime like any agentic function that calls the LLM (the decorator uses it to set up context). You just do not pass runtime= as an argument to decision.make — it reads the ambient runtime itself.
from openprogram.agentic_programming import agentic_function, decision
from openprogram.agentic_programming.runtime import Runtime
@agentic_function
def route_message(msg: str, runtime: Runtime = None) -> dict:
"""Decide how to handle an incoming message."""
return decision.make(f"Pick how to handle this message:\n{msg}", {
"analyze": analyze_sentiment, # function option — runs it, returns its result
"fallback": fallback_reply, # function option
"done": {"action": "ignored"}, # value option — returns the value as-is
})
@agentic_function
def handle_ticket(ticket: str, runtime: Runtime = None) -> dict:
"""Investigate a ticket, then decide which workflow to route it to."""
return runtime.exec(
f"Handle this ticket:\n{ticket}",
toolset="default", # the model does the work with tools first
choices={ # the final return must be one of these
"refund": issue_refund,
"escalate": escalate_to_human,
"close": {"status": "closed"},
},
)
Option forms — options / choices is a dict {name: handler} or a list of callables / option tuples. A handler can be:
- a callable — a function option; picking it runs the function, its return value is the result
- a value — a value option; picking it returns that value as-is (
{name: (value, "description")}adds a description) - a
("description", schema)pair — a schema option; the model fills the schema and the filled structure is returned as{"decision": name, **fields}. Theschemais{field: type}and nests:[item]for a list,{sub: ...}for a nested object — so one option can ask for any structured JSON
The resolution leaves no if for you to write: a picked function runs, a picked value/structure comes back. The decision is the branch. If the model never produces a resolvable pick after retries, DecisionError (a ValueError subclass) is raised — catch it if you want a graceful fallback.
Rule: if you find yourself writing runtime.exec(...) followed by if "..." in reply: to route on what the model said, replace it with decision.make / exec(choices=).
6. Rule-based validation — run this before declaring done
Walk through every rule. If any fails, fix it before writing the file.
6.1 Structural rules
| # | Rule | How to check |
|---|---|---|
| 1 | File contains at most one entry function (one @agentic_function-decorated def at top level). |
Eyeball / grep. |
| 2 | If the function makes any runtime.exec call, the entry function must be decorated with @agentic_function. |
Eyeball. |
| 3 | If decorated, the signature must include runtime: Runtime — and give it a default (runtime: Runtime = None). A function passed to exec(tools=[...]) fails at tool dispatch with TypeError: missing a required argument: 'runtime' before injection if the parameter has no default; direct Python calls and the decision/choices path do inject. |
Eyeball signature. |
| 4 | Every parameter has a type annotation; the return has an annotation. | Eyeball signature. |
| 5 | The function has a docstring whose first paragraph is a one-line summary. | Eyeball. |
| 6 | async def bodies are supported (the decorator wrapper has an async branch), but sync def remains the default recommendation: Runtime.async_exec exists but lacks tools= / choices= support. |
Eyeball. |
| 7 | Every import / from ... import actually resolves in this environment. There is no import sandbox — any installed package works — but prefer the stdlib and openprogram.* so the function stays dependency-free and portable. |
Read import lines; the §9 smoke test catches anything that doesn't resolve. |
| 8 | If you pull in a third-party package (not stdlib, not openprogram.*), confirm it's already installed and tell the user it's a new dependency. |
Check / pip show <pkg>. |
| 9 | Code parses as Python (no syntax error). | Mentally compile; if unsure run python -c "import ast; ast.parse(open('<path>').read())". |
6.2 runtime.exec call-site rules (every runtime.exec(...) call)
| # | Rule | How to check |
|---|---|---|
| 10 | content= is canonically a list[dict]. |
Eyeball. |
| 11 | Each item in a content list is a dict literal — content=[text] (a bare string inside the list) is wrong. Passing a plain string as content itself is accepted and auto-wrapped into a text block; the list-of-blocks form is canonical. content=[{"type":"text","text":text}] is the canonical shape. |
Eyeball. |
| 12 | Each dict has "type": "text" (with "text") or "image" / "audio" / "file" (with "path"). |
Eyeball. |
| 13 | Allowed kwargs only: content, response_format, model, tools, toolset, tools_source, tools_allow, tools_deny, tool_choice, parallel_tool_calls, max_iterations, choices. tool_choice / parallel_tool_calls forward to the provider; max_iterations caps the tool-loop rounds at min(50, value). |
Eyeball. |
| 14 | No system= kwarg. System instruction comes from the decorator (system="...") or the docstring, never from a runtime.exec kwarg. |
Eyeball. |
| 15 | The per-call instruction lives inside the content text, not only in the docstring (see §4). |
Eyeball — the text in content should describe the task. |
6.3 @agentic_function(input=...) rules
For every LLM-visible parameter (not runtime, not hidden=True):
| # | Rule | How to check |
|---|---|---|
| 16 | The parameter appears as a key in input={...}. |
Eyeball. |
| 17 | The entry has at least description. |
Eyeball. |
| 18 | If options= is declared, all elements are strings (or other JSON-serializable scalars). |
Eyeball. |
6.4 Style rules (docstring + content)
| # | Rule | How to check |
|---|---|---|
| 19 | No role-play in the docstring ("You are a helpful assistant"). | Eyeball. |
| 20 | No empty directives ("Complete the task", "Do your best"). | Eyeball. |
| 21 | The per-call content text defines the exact output format expected; don't leave it for the LLM to guess. |
Eyeball. |
| 22 | The docstring may explain what the call does at any level of detail. But the LLM doesn't read it as instruction — the prompt in content does the work. |
Eyeball. |
7. WebUI rendering behavior (good to know when designing parameters)
The form sidebar renders each parameter by these rules:
| Trait | Control |
|---|---|
bool type |
Yes / No toggle buttons |
str type, multiline not set |
Defaults to textarea (multiline=True implied) |
str type, multiline: False |
Single-line <input> |
Non-str non-bool, no multiline |
Single-line <input> |
options: [...] |
Clickable chips + free-form text input |
options_from: "functions" |
Dropdown populated from registered functions |
hidden: True |
Omitted from the form entirely |
Has a Python default and no explicit placeholder |
Placeholder set to "default: <value>" |
workdir_mode controls the working-directory picker:
| Value | Effect |
|---|---|
"optional" (default) |
Picker shown; can be empty |
"hidden" |
Picker hidden |
"required" |
Picker shown and required |
8. Editing an existing function
Same rules, but:
- Use
Readto load the current source. - Preserve the function name, parameter names and order, type hints, and existing
@agentic_function(input=..., ...)arguments unless the user's instruction explicitly asks to change them. - Never change
runtime: RuntimetoAny. - Apply
Editfor targeted changes; onlyWritewhen rewriting the whole file. - If there's a sibling
tests/file for this function, update or add a test for the change.
For "fix this bug" requests, look at the function's error log first if available (e.g. recent traceback in the conversation), then edit to address the root cause, not the symptom.
9. Smoke test after writing
When the function is new or significantly changed, run a quick import-and-call check:
python -c "
from <path.to.module> import <fn_name>
from openprogram.providers.registry import create_runtime
rt = create_runtime()
result = <fn_name>(..., runtime=rt)
print(result)
rt.close()
"
If it crashes, read the traceback and fix before declaring done. For functions whose output is hard to verify automatically (free-form text), the smoke test only proves "didn't crash" — write a real pytest test in tests/ for anything important.
10. Running an existing function
Once a function is saved, there are two ways to run it.
CLI — for functions discoverable under openprogram/functions/agentics/ (listed in openprogram/functions/_registry.py::AGENTIC_MODULES):
openprogram programs list # see what's available
openprogram programs run <name> --arg key=value # --arg is repeatable
The CLI run path auto-injects a Runtime for functions that need one, so
you don't pass runtime= yourself. --provider / --model override the
LLM if the function calls one.
Python — import and call directly (any function, anywhere):
from openprogram.functions.agentics.<name> import <name>
from openprogram.providers.registry import create_runtime
rt = create_runtime()
result = <name>(..., runtime=rt) # pass runtime= only if the signature has it
rt.close()
Direct calls do not auto-inject a runtime — construct one with
create_runtime() and pass it. This is also the §9 smoke-test shape.
11. Generating a SKILL.md for a function
To make a function agent-discoverable, write a SKILL.md next to it:
<path>/<fn_name>/SKILL.md
Frontmatter format (YAML):
---
name: <fn_name>
description: "<one-sentence summary including 4-8 trigger phrases an agent might use>"
---
After the frontmatter, write a short body covering when to use this skill, brief usage example, and one or two natural-language triggers. Keep it concise — agents read this every message.
12. Sanity checks if something looks wrong
| Symptom | Likely cause |
|---|---|
| Generated function returns the wrong thing when run | Per-call prompt isn't in content=[...] — only in docstring. Codex / chatgpt subscription will reply conversationally. Move the instruction into content. |
| WebUI doesn't show your function | Filename starts with _, or the file isn't under one of the discovery roots. |
Function crashes with ImportError |
The imported package isn't installed in this environment. Install it, or rewrite using a stdlib / openprogram.* equivalent (see §6.1 rule 7). |
| Same function works on one provider, fails on another | Provider treats the rendered context tree differently. Make sure content=[...] is self-sufficient — the test should be: would this work if the function had no docstring at all? If yes, you're good. |
13. Quick-reference: the absolute minimum
If you remember nothing else from this skill, remember these:
- Decorate with
@agentic_functiononly when LLM reasoning is needed. runtime: Runtime = Nonein the signature;runtime.exec(content=[{"type":"text","text":"..."}])is the call shape.- Per-call prompt + data go in
content. Docstring is documentation, not instruction. - No
system=kwarg onruntime.exec. - Every LLM-visible parameter needs a
descriptionininput={...}. - No
Args:/Returns:sections in the docstring. - Save to
openprogram/functions/agentics/<name>/__init__.pyunless the user said otherwise. Add("<name>", None)toopenprogram/functions/_registry.py::AGENTIC_MODULESso the loader actually imports it (otherwise the @agentic_function decorator never fires and the function won't be discoverable). - If the LLM should also see it as a callable tool, add
"<name>"toopenprogram/functions/__init__.py::TOOLSETS["full"]["tools"](the Layer 2 exposure whitelist). Without this the function exists but is invisible to LLMs.