Back to MCP

MCP guides · Sep 15, 2026

OpenAI Agents SDK cron jobs with approved API tools

Wrap Crontap's Ultra REST API in narrow function tools, load credentials from server environment, and approve each operation.
crontap.com / mcp guides
Build runnable OpenAI Agents SDK function tools for Crontap's Ultra API, require approval for each call, and keep credentials outside model context.

An OpenAI Agents SDK application can manage Crontap schedules through the REST API wrapped in local, approval-gated function tools. Credentials stay in server environment variables, tool inputs accept only reviewed fields, and tool outputs expose only schedule data. This requires a Crontap Ultra account because public API access is an Ultra feature. The direct MCP OAuth limitation is explained below.

Before you start

  • Install the current openai-agents Python package and configure an OpenAI API key for the SDK.
  • Copy your Crontap ApiKey and ClientId into server environment variables. Never place them in agent instructions, model input, tool arguments, or tool output.
  • Set CRONTAP_ALLOWED_TARGET_HOSTS to the exact deployed hosts this agent may schedule.
  • Make the target endpoint idempotent before allowing retries or repeated approvals.
  • Verified against OpenAI Agents SDK function tools, human-in-the-loop approvals, and Crontap's public API reference on 2026-09-15.

Wrap the Crontap API in approved function tools

The current Python SDK documents @tool as the function-tool decorator and needs_approval=True as the manual approval gate. The application below exposes two narrow operations: preview plain English through POST /v1/schedule/preview, then create a fixed POST schedule through POST /v1/schedule.

Step 1

Configure server-only environment values

Set CRONTAP_API_KEY, CRONTAP_CLIENT_ID, and CRONTAP_ALLOWED_TARGET_HOSTS in the process that runs your agent. The code reads them only inside the HTTP helper. The model cannot submit headers, credentials, arbitrary methods, or response field names.

import asyncio
import json
import os
from typing import Any
from urllib.error import HTTPError
from urllib.parse import urlsplit
from urllib.request import Request, urlopen
from zoneinfo import ZoneInfo
 
from agents import Agent, Runner
from agents.decorators import tool
 
API_BASE = "https://api.crontap.com"
OUTPUT_FIELDS = {
    "id",
    "status",
    "url",
    "verb",
    "interval",
    "timezone",
    "label",
    "cron",
    "description",
    "nextRuns",
    "question",
    "options",
    "roundsLeft",
    "reason",
}
SCALAR_OUTPUT_FIELDS = OUTPUT_FIELDS - {"nextRuns", "options"}
OPTION_OUTPUT_FIELDS = {"label", "cron", "recommended"}
 
 
def api_request(path: str, body: dict[str, Any]) -> Any:
    request = Request(
        f"{API_BASE}{path}",
        data=json.dumps(body).encode(),
        method="POST",
        headers={
            "ApiKey": os.environ["CRONTAP_API_KEY"],
            "ClientId": os.environ["CRONTAP_CLIENT_ID"],
            "Content-Type": "application/json",
        },
    )
    try:
        with urlopen(request, timeout=15) as response:
            return json.load(response)
    except HTTPError as error:
        detail = error.read(1000).decode(errors="replace")
        raise RuntimeError(
            f"Crontap API returned {error.code}: {detail}"
        ) from error
 
 
def safe_output(payload: Any) -> dict[str, Any]:
    if not isinstance(payload, dict):
        raise RuntimeError("Unexpected Crontap response")
    result = {
        key: payload[key]
        for key in SCALAR_OUTPUT_FIELDS
        if isinstance(payload.get(key), (str, int, float, bool))
    }
    if isinstance(payload.get("nextRuns"), list):
        result["nextRuns"] = [
            value
            for value in payload["nextRuns"]
            if isinstance(value, str)
        ]
    if isinstance(payload.get("options"), list):
        result["options"] = [
            {
                key: option[key]
                for key in OPTION_OUTPUT_FIELDS
                if key in option
                and (
                    isinstance(
                        option[key], (str, int, float, bool)
                    )
                    or key == "cron"
                    and option[key] is None
                )
            }
            for option in payload["options"]
            if isinstance(option, dict)
        ]
    return result
 
 
@tool(needs_approval=True)
def preview_crontap_schedule(
    text: str,
    timezone: str = "UTC",
) -> dict[str, Any]:
    """Preview schedule text without saving a resource."""
    ZoneInfo(timezone)
    if not 1 <= len(text.strip()) <= 500:
        raise ValueError("text must contain 1 to 500 characters")
    result = api_request(
        "/v1/schedule/preview",
        {"text": text.strip(), "timezone": timezone},
    )
    return safe_output(result)
 
 
@tool(needs_approval=True)
def create_crontap_post_schedule(
    url: str,
    interval: str,
    timezone: str,
    label: str,
) -> dict[str, Any]:
    """Create one POST schedule for an approved HTTPS host."""
    parsed = urlsplit(url)
    allowed_hosts = {
        host.strip().lower()
        for host in os.environ[
            "CRONTAP_ALLOWED_TARGET_HOSTS"
        ].split(",")
        if host.strip()
    }
    if (
        parsed.scheme != "https"
        or not parsed.hostname
        or parsed.username
        or parsed.password
        or parsed.hostname.lower() not in allowed_hosts
    ):
        raise ValueError("URL host is not approved")
    ZoneInfo(timezone)
    if not 1 <= len(label.strip()) <= 120:
        raise ValueError("label must contain 1 to 120 characters")
    result = api_request(
        "/v1/schedule",
        {
            "url": url,
            "verb": "POST",
            "interval": interval,
            "timezone": timezone,
            "label": label.strip(),
        },
    )
    return safe_output(result)
 
 
agent = Agent(
    name="Crontap schedule manager",
    instructions=(
        "Preview first. Show the exact schedule fields. "
        "Create only after the person approves the tool call."
    ),
    tools=[
        preview_crontap_schedule,
        create_crontap_post_schedule,
    ],
)
 
 
def approve(tool_name: str, arguments: str | None) -> bool:
    answer = input(
        f"Approve {tool_name} with {arguments}? [y/N] "
    )
    return answer.strip().lower() in {"y", "yes"}
 
 
async def main() -> None:
    request = (
        "Preview weekdays at 07:45 in Europe/Paris. "
        "Then create partner feed refresh for "
        "https://api.example.com/partners/refresh."
    )
    result = await Runner.run(agent, request)
    while result.interruptions:
        state = result.to_state()
        for item in result.interruptions:
            if approve(item.name or "unknown_tool", item.arguments):
                state.approve(item, always_approve=False)
            else:
                state.reject(item)
        result = await Runner.run(agent, state)
    print(result.final_output)
 
 
if __name__ == "__main__":
    asyncio.run(main())
python app.py

Step 2

Review the preview interruption

Run the application with api.example.com in the host allowlist. The first interruption should contain text and timezone only. Approve it and inspect the returned cron, description, timezone, and three upcoming runs. If the API returns needs_clarification, answer its question and preview the revised wording again. If it returns rejected, do not create a schedule.

Step 3

Approve the exact create call

The second interruption should show the HTTPS URL, cron interval, timezone, and label. The tool fixes the HTTP method to POST and rejects hosts outside the environment allowlist. Approve once with always_approve=False, so a later create call pauses independently.

Verify the runnable outcome

The terminal should show two separate approval requests. Rejecting either request prevents that tool call. A successful create response should include one schedule ID and allowlisted fields only. It cannot include the Crontap API credentials because the helper never adds request headers to the returned object.

Open the same schedule in Crontap and confirm the label, production URL, POST method, cron, timezone, and next run. Add the endpoint secret only in the Crontap UI, not in the agent request or tool arguments, then perform a test request.

Check the target service logs for the matching POST /partners/refresh, then verify that the partner feed timestamp advanced. Crontap history proves the HTTP request ran; the service result proves the application work completed.

Direct Crontap MCP OAuth is not available here yet

The Agents SDK supports hosted MCP and local Streamable HTTP tools, but its current documentation does not provide an arbitrary browser OAuth flow plus token persistence for this Python application. Crontap's MCP server requires that OAuth flow. Do not substitute a Crontap API key as an MCP bearer token.

The REST function tools above are the working integration for Ultra accounts. They preserve approval and keep API credentials server-side. If the SDK later documents a complete arbitrary-server OAuth client, the application can evaluate direct MCP separately.

Function tools do not schedule the agent

Adding a function tool gives a running agent permission to request an API operation. It does not call Runner.run tomorrow. The created Crontap resource independently sends recurring HTTP requests to the deployed partner endpoint. Your Agents SDK application still starts from its own web request, queue message, CLI invocation, or platform trigger.

Troubleshooting

The API returns 401

Confirm that CRONTAP_API_KEY and CRONTAP_CLIENT_ID belong to the same Ultra account and are available to the server process. Do not print either value while debugging.

The URL host is not approved

Add the exact lowercase hostname to CRONTAP_ALLOWED_TARGET_HOSTS. Keep the list narrow. The tool intentionally rejects HTTP, embedded credentials, and unlisted hosts.

The run does not pause

Use the current @tool(needs_approval=True) decorator. Inspect result.interruptions, convert the result with to_state(), record a decision, and resume the original top-level agent.

The API rejects the cron or timezone

Approve the preview first and pass its returned cron into the create request. Use an IANA timezone accepted by Crontap. The local ZoneInfo check catches unknown names before the API call.

A rejected action is proposed again

A rejection applies to that tool call. The model can propose a new call after reading the rejection. Review it separately, or end the run when no further action is appropriate.

Next steps

Crontap API access is available on Ultra. Compare Crontap plans →

Verified against current official OpenAI Agents SDK and Crontap API documentation on 2026-09-15. Sources: Agents SDK tools, Agents SDK human-in-the-loop, and Crontap public API.

FAQ

OpenAI Agents SDK cron job questions

Can an OpenAI Agents SDK app manage Crontap today?
Yes. An Ultra account can call Crontap's public API from narrow local function tools. Keep ApiKey and ClientId in the server environment and require approval before each operation.
Why does the working example use REST instead of direct MCP?
The current Agents SDK documentation does not provide arbitrary browser OAuth and token persistence for Crontap's remote MCP server. The authenticated public API is runnable for Ultra accounts.
How do I require approval for Crontap API tools?
Decorate each function with @tool(needs_approval=True). The run exposes interruptions that your app approves or rejects before resuming the original agent.
Does adding Crontap tools schedule the agent itself?
No. The tools let a running application agent manage Crontap. Your application still needs its own web request, queue message, CLI invocation, or platform trigger to run the agent.

From the blog

Read the blog

Guides, patterns and product updates.

Tutorials on scheduling API calls, webhooks and automations, plus deep dives into cron syntax, timezones and reliability.

Product Updates

Introducing AI Integrations

Transform a schedule's HTTP response with a plain-English prompt, return text or JSON, and forward it to Slack, Make, n8n, or your own endpoint. Test on any tier; saving is a Pro feature.

Alternatives

Vercel cron jobs: the Hobby once-per-day limit and how to beat it

Vercel Cron caps Hobby at one run per day, only guarantees timing within the hour, is UTC only, and ties every schedule change to a redeploy. Here is the external cron pattern teams use to ship per-minute, timezone-aware schedules and one dashboard across projects without paying $20/mo per user for Pro.