Magic functions and agents have two mutually exclusive premise and system parameters on initialization:
Premise. Provides additional context in the AI’s system prompt about what its goal is going to be.
System. Provides completely custom behaviour by overriding the AI’s system prompt entirely.
When the AI is invoked, you provide further “task” messages which specify more specifically the goal at that moment.If the system parameter is provided, future “task” messages for agents will be provided with no additional formatting, meaning you need to be explicit about everything the agent has access to and needs to return. Custom prompts benefit from dropping in snippets of “explainers” for things such as the REPL and return types; these may be provided via {{TEMPLATE}} variables, explained in detail in Advanced › System Prompt Templating.Whilst writing prompts for the system message or individual task messages, there are some best practices to follow which will vary depending on the model you are using. These are laid out in detail per model by provider documentation.
When using OpenAI models (openai:gpt-4.1, openai:gpt-5, etc.) with Agentica, apply these proven strategies:Writing Magic Function Docstrings
Copy
# Bad - Vague docstring@magic()def analyze(text: str) -> dict: """Analyze text""" ...# Good - Clear, specific docstring@magic()def analyze(text: str) -> dict[str, Any]: """ Analyze the sentiment, key entities, and main topics in the text. Return a dict with 'sentiment' (positive/negative/neutral), 'entities' (list of names/places/orgs), and 'topics' (list of main themes). """ ...
Crafting Agent Premises
Copy
# Bad - Generic premiseagent = await spawn(premise="You are helpful.")# Good - Specific premise with clear role and constraintsagent = await spawn( premise=""" You are a data analyst specializing in customer feedback. Always provide numerical confidence scores (0-1) with your conclusions. When uncertain, explicitly state your assumptions. """)
Request Step-by-Step ReasoningFor complex tasks, explicitly ask for reasoning in your docstrings or premises:
Copy
@magic()def solve_problem(problem: str) -> dict[str, Any]: """ Solve the math problem step by step. First, identify what's being asked. Then, break down the solution into steps. Finally, provide the answer with your reasoning. """ ...
Leverage Scope EffectivelyProvide focused, relevant tools rather than entire SDKs:
Copy
from slack_sdk import WebClientslack = WebClient(token=TOKEN)# Good - Extract only what you need@magic(slack.users_list, slack.chat_postMessage)async def notify_team(message: str) -> None: """Send message to all active team members.""" ...
Type Hints are InstructionsOpenAI models excel at following type hints—use them to guide output:
Copy
from typing import Literal@magic()def classify(text: str) -> Literal['urgent', 'normal', 'low']: """Classify the priority of this support ticket.""" ...
When using Anthropic models (anthropic:claude-sonnet-4.5, anthropic:claude-opus-4.1, etc.) with Agentica, leverage these Claude-specific strengths:XML Tags in DocstringsClaude excels at parsing XML structure—use it in complex magic functions:
Copy
@magic()def extract_and_validate(document: str, schema: dict) -> dict: """ Extract structured data from the document and validate against schema. <instructions> 1. Parse the document and extract fields matching the schema 2. Validate each field against schema constraints 3. Return extracted data with validation status </instructions> <output_format> Return dict with 'data' (extracted fields) and 'valid' (bool) </output_format> """ ...
Rich Agent PremisesClaude responds well to detailed role definitions:
Copy
agent = await spawn( premise=""" You are a senior software architect with expertise in distributed systems. <role> - Analyze system designs for scalability issues - Suggest concrete improvements with trade-offs - Consider cost, latency, and reliability </role> <style> - Be direct and technical - Provide specific code/config examples when helpful - Acknowledge uncertainties explicitly </style> """, model="anthropic:claude-sonnet-4.5")
Chain of Thought PromptsClaude’s reasoning improves dramatically with explicit thinking requests:
Copy
@magic()def debug_issue(code: str, error: str) -> dict[str, str]: """ Debug the code issue by thinking through it step by step. Before providing a solution: 1. Analyze what the code is trying to do 2. Identify why the error occurs 3. Consider multiple potential fixes 4. Choose the best fix with explanation Return dict with 'analysis' and 'fix' keys. """ ...
System Prompts for Complete ControlUse system instead of premise when you need to fully specify behavior:
Copy
# For agents that must return ONLY structured dataagent = await spawn( system=""" You are a data extraction API. You receive documents and schemas. You MUST return valid JSON matching the schema. Never include explanations, preambles, or markdown formatting. Just the JSON object. """, model="anthropic:claude-sonnet-4.5")
Long Context UtilizationClaude handles large contexts exceptionally well—structure them clearly:
Copy
@magic()def analyze_codebase(files: dict[str, str]) -> dict: """ Analyze the entire codebase for security issues. <critical_instructions> Focus on: SQL injection, XSS, auth bypasses, secrets in code </critical_instructions> Process each file systematically. Look for patterns across files. <critical_instructions> Return findings with file paths and severity (critical/high/medium/low) </critical_instructions> """ ...
XML tags preferred. Use <instructions>, <context>, <examples> for structure.
Detailed premises. Responds well to longer, more elaborate role definitions.
Chain of thought. Explicitly request thinking with “Before answering, think step by step…”
Long context friendly. Can handle very large prompts and scope effectively.
Universal Agentica Best PracticesRegardless of model choice:1. Write Clear Docstrings/Descriptions. Be specific about what, how, and what format to return.
Copy
# Magic functions: detailed docstrings@magic()def process(data: str) -> Result: """What, how, and what format to return""" ...# Agents: specific premisesagent = await spawn(premise="Clear role + constraints")
2. Use Strong Type Hints. Types guide the AI and ensure type-safe returns.
3. Provide Focused Scope. Only include tools/data the AI needs for the specific task, not entire objects or SDKs.4. Request Reasoning for Complex Tasks. Add “step by step” or “think through” to prompts for better accuracy on hard problems.5. Test with Real Examples. Validate magic functions and agents with actual use cases before production.
Vague prompts lead to inconsistent results. The AI needs clear instructions about what you want, how you want it, and what format to return. Think of your docstring as a specification, not just a description.Bad approach: Generic verbs without details.
Copy
@magic()def summarize(text: str) -> str: """Summarize the text""" ...
Good approach: Specify length, focus, and style.
Copy
@magic()def summarize(text: str) -> str: """ Create a 2-3 sentence summary of the text. Focus on the main argument and key supporting points. Use objective language without opinion. """ ...
When behavior or style matters beyond just the output structure, specify it clearly. Types tell the AI what to return, but your prompt tells it how to get there.
Copy
@magic()def extract_sql(user_request: str, schema: dict) -> str: """ Generate a SQL query from the user's natural language request. Requirements: - Use only SELECT statements (no INSERT, UPDATE, DELETE) - Always include LIMIT clauses to prevent large result sets - Use table aliases for readability - When joining tables, prefer INNER JOIN over implicit joins - Add comments explaining complex WHERE clauses Return valid PostgreSQL syntax. """ ...
When you need specific formatting or a particular style, showing examples is more effective than describing the desired output in words. AI models learn patterns quickly from concrete examples.Use examples for tasks where the output has specific structure, like generating changelog entries:
Copy
@magic()def write_changelog_entry(commit_messages: list[str]) -> str: """ Write a changelog entry from commit messages. Example input: ["fix: resolve login timeout", "feat: add dark mode"] Example output: ### Features - Added dark mode support ### Bug Fixes - Fixed login timeout issue Follow this format exactly. """ ...
Multiple examples help establish patterns, especially for formatting that varies by input:
Copy
@magic()def format_currency(amount: float, currency: str) -> str: """ Format a currency amount for display. Examples: - format_currency(1234.50, "USD") → "$1,234.50" - format_currency(999.99, "EUR") → "€999.99" - format_currency(50, "GBP") → "£50.00" Always include the currency symbol and exactly 2 decimal places. """ ...
The AI needs explicit rules for handling edge cases and ambiguous inputs. Without clear constraints, you’ll get inconsistent behavior when inputs don’t match the happy path.When your task involves categorization or decision-making, spell out the criteria:
Copy
from typing import Literal@magic()def categorize_severity(error_message: str, stack_trace: str) -> Literal['critical', 'high', 'medium', 'low']: """ Categorize error severity. Constraints: - Return 'critical' if: data loss, security breach, system crash - Return 'high' if: feature unusable, affects multiple users - Return 'medium' if: degraded performance, workaround available - Return 'low' if: cosmetic issue, minimal impact Edge cases: - If stack_trace is empty, base decision on error_message alone - Database errors are at minimum 'high' severity - Authentication errors are at minimum 'high' severity """ ...
For parsing or extraction tasks, define what constitutes valid input and what to return when inputs are missing or malformed:
Copy
@magic()def parse_date_range(text: str) -> tuple[str, str] | None: """ Extract start and end dates from natural language. Return format: (start_date, end_date) as YYYY-MM-DD strings Constraints: - Start date must be before or equal to end date - Return None if no date range found - Return None if only one date found (need both) Edge cases: - "last week" → Monday to Sunday of previous week - "Q1 2024" → 2024-01-01 to 2024-03-31 - Relative dates use today's date as reference """ ...
For multi-step validation workflows, use an agent that progressively checks and adapts based on what it discovers. The agent remembers previous findings when deciding next steps:
Copy
from agentica import spawnfrom typing import Literalagent = await spawn(premise="You are a security validator for user queries")# Step 1: Agent analyzes input for safetysafety = await agent.call( Literal['safe', 'sql_injection', 'invalid_chars'], "Classify this user input for security issues", user_input=untrusted_input)# Step 2: Based on what agent found, take different actionsif safety == 'safe': # Agent remembers the input it just analyzed normalized = await agent.call( str, "Normalize the input you just validated (lowercase, trim, remove extra spaces)" ) return normalizedelif safety == 'sql_injection': # Agent remembers what SQL patterns it detected details = await agent.call( str, "Explain which SQL patterns you detected and why they're dangerous" ) log_security_violation(details) raise SecurityError(details)else: # Agent remembers the invalid characters it found suggestion = await agent.call( str, "Suggest what the user should change about their input" ) return f"Invalid input. {suggestion}"
Strong type hints do two things: they guide the AI toward the correct output structure, and they give you type-safe returns in your code. The more specific your types, the more constrained the AI’s output will be.Use literal types to restrict outputs to specific values:
Copy
from typing import Literal@magic()def classify(text: str) -> Literal['positive', 'negative', 'neutral']: """Classify sentiment""" ...# The AI can only return one of these three exact stringsresult = classify("Great product!") # Type is Literal['positive', 'negative', 'neutral']
Use structured types for complex outputs. The AI will match your type structure exactly:
Never hardcode API keys or secrets. Use environment variables. This keeps credentials out of your codebase and allows different values per environment.
Copy
import os# Good - use environment variablesapi_key = os.environ["API_KEY"]database_url = os.environ.get("DATABASE_URL")# Bad - hardcoded secretsapi_key = "sk-proj-abc123..." # Never commit this
Never pass raw API keys to agents. Instead, pass pre-authenticated SDK clients or specific methods. The agent uses the functionality without ever seeing the credentials:
Copy
from agentica import spawnfrom github import Github# Good - pass authenticated client methodsgh = Github(os.environ["GITHUB_TOKEN"])agent = await spawn(premise="You are a GitHub analyst")result = await agent.call( Report, "Analyze the repository's recent activity", get_repo=gh.get_repo, search_issues=gh.search_issues)# Agent can use GitHub API without accessing the token# Bad - passing raw credentialsresult = await agent.call( Report, "Analyze repository", github_token=os.environ["GITHUB_TOKEN"] # Never do this)
Validate user input before passing it to AI functions. This prevents injection attacks and ensures your magic functions receive clean data.
Copy
from agentica import magic@magic()def query_database(user_input: str, schema: dict) -> list[dict]: """ Generate and execute a database query based on user input. Only generate SELECT queries. Use the schema to validate table/column names. """ ...def safe_query(user_input: str) -> list[dict]: # Validate input length if len(user_input) > 500: raise ValueError("Input too long") # Check for suspicious patterns dangerous_keywords = ['drop', 'delete', 'truncate', 'insert', 'update'] if any(keyword in user_input.lower() for keyword in dangerous_keywords): raise ValueError("Invalid query keywords") # Now safe to pass to AI return query_database(user_input, schema)
AI that can open arbitrary paths can easily escape its intended sandbox (for example by traversing ../) and read, modify, or delete files across your system. Avoid passing Path objects or unrestricted file paths directly to agents or magic functions. Instead, pre-open only the specific files you want the AI to access and pass those file handles in scope.
Copy
from typing import TextIOfrom agentica import magic@magic()def summarize_report(report_file: TextIO) -> str: """ Read the already-open report_file and summarize its contents. """ ...with open("/var/reports/weekly.csv", "r", encoding="utf-8") as f: # The AI only sees this specific handle, not your whole filesystem summary = summarize_report(f)
Never log sensitive data. User inputs, API keys, or PII should not appear in logs. See Error Handling › Sensitive Data Handling for examples of safe logging practices.
Cache AI responses when the same inputs produce the same outputs. This reduces latency and costs for repeated operations.Use caching for:
Reference data that changes infrequently (product descriptions, documentation)
Expensive operations called repeatedly with the same inputs
Read-heavy workflows where consistency is acceptable
Copy
from functools import lru_cache# Decorate the magic function directly@lru_cache(maxsize=1000)@magic()def categorize_product(description: str) -> str: """Categorize product into a department""" ...# Same description returns cached resultcategory1 = categorize_product("Red cotton t-shirt") # Calls AIcategory2 = categorize_product("Red cotton t-shirt") # Returns cached
Advanced: Best-of-N caching with retries. Like JIT compilation that eventually compiles hot code paths, you can combine caching with retry strategies to create a “best-of-N” pattern: retry failed operations until you get a high-quality result, then cache that successful response. Future calls skip the retry logic entirely and use the cached “compiled” result. This is particularly useful for expensive operations where you want to pay the retry cost once, then reuse the validated output.
Process multiple items in parallel when they’re independent. This is faster than sequential processing.
Copy
import asyncio@magic()async def analyze(text: str) -> dict: """Analyze the text""" ...# Process all texts in paralleltexts = ["text 1", "text 2", "text 3"]results = await asyncio.gather(*[analyze(text) for text in texts])
Use agents for multi-step workflows where later steps depend on earlier results. Agents maintain context across invocations, allowing them to make decisions based on what they’ve already done.Here’s an agent that debugs code by analyzing, then deciding whether to fix or explain based on what it finds:
Copy
from agentica import spawnagent = await spawn( premise=""" You are a code debugger. When given code with an error: 1. First analyze the error to understand the root cause 2. If it's a simple fix (syntax, typo), fix it and return the corrected code 3. If it's a logic error requiring design changes, explain the issue instead """, model="openai:gpt-4.1")# First invocation: analyzeawait agent.call(None, "Analyze this error", code=broken_code, error=error_msg)# Second invocation: agent decides to fix or explain based on analysisresult = await agent.call( str, "Based on your analysis, either fix the code or explain what needs to change")# The agent remembers its analysis and chooses the appropriate action
For truly independent operations, use magic functions and process in parallel. For dependent workflows where context matters, use a single agent across multiple calls.
AI operations cost money—optimize by choosing the right model, caching responses, and using agents only when needed.Choose the right model for the task. Use cheaper models for simple operations, more expensive models for complex reasoning. See Model Selection for guidance.Cache aggressively. Every cache hit is a cost you don’t pay. See Caching above.Keep prompts concise. Longer prompts cost more. Remove unnecessary context or examples once you’ve validated your magic function works.Use agents strategically. Agents maintain conversation history, which grows with each call and costs more. For stateless operations, use magic functions instead.Bad: Using an agent for independent operations
Copy
# Inefficient - agent maintains unnecessary historyagent = await spawn(premise="You are a data processor")for item in items: result = await agent.call(dict, f"Process this item: {item}") # Each call adds to history, increasing cost
Good: Using magic function for independent operations
Copy
@magic()def process_item(item: str) -> dict: """Process the item""" ...# Each call is independent, no growing historyfor item in items: result = process_item(item)
Good: Using agent when context matters
Copy
# Agent remembers context across stepsagent = await spawn(premise="You are a research assistant")# Step 1: Find relevant paperspapers = await agent.call(list[str], "Search for papers on quantum computing", web_search=search)# Step 2: Agent remembers which papers it foundsummary = await agent.call(str, "Summarize the key findings from these papers")# Step 3: Agent has full context to comparecomparison = await agent.call(str, "Which paper has the most practical applications?")