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-agentsPython package and configure an OpenAI API key for the SDK. - Copy your Crontap
ApiKeyandClientIdinto server environment variables. Never place them in agent instructions, model input, tool arguments, or tool output. - Set
CRONTAP_ALLOWED_TARGET_HOSTSto 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.pyStep 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
- Return to the Crontap MCP hub for interactive OAuth client guides.
- Compare approval behavior in Devin Local and Gemini CLI.
- Read Scheduled AI jobs for application trigger patterns.
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.
