# Scheduled AI jobs with your own Claude, OpenAI or Grok key

Run a prompt on a schedule and send the answer on: a morning summary to Discord, a triage of new tickets to Slack, or JSON that the next step posts to your API. The `ai_generate` step calls Anthropic, OpenAI or xAI with your own API key, so you pick the model and usage is billed to your AI account. It can also call your APIs, and the tools of remote MCP servers, while it works.

## Example: a daily summary to Discord

The reply is text, so the Discord step sends all of it with `{{ $ }}`. `max_chars` is Discord's 2000-character limit: the model is told, and a reply that runs over is sent back once to be shortened.

```json
{
  "name": "Open PRs, summarized for #eng",
  "cron": { "expression": "0 9 * * 1-5", "time_zone": "Europe/Berlin" },
  "nodes": [
    { "id": "prs", "request_template": { "template_id": "<id of github: List pull requests>", "credential_id": "YOUR_GITHUB_CREDENTIAL_ID", "values": { "owner": "acme", "repo": "web", "state": "open" } } },
    { "id": "sum", "ai_generate": {
        "provider": "anthropic", "model": "claude-opus-5-5", "credential_id": "YOUR_ANTHROPIC_KEY_ID", "effort": "low",
        "system": "You write the morning update for an engineering team. Format it as Discord markdown: **bold**, *italic*, `code`, and - for lists. No tables.",
        "prompt": "Summarize these open pull requests. Put anything waiting on review for more than two days first: {{ $ }}",
        "text": { "max_chars": 2000 } } },
    { "id": "post", "request_template": { "template_id": "<id of discord: Send message>", "values": { "webhook_url": "https://discord.com/api/webhooks/123456789012345678/abcDEF_ghi-JKL", "content": "{{ $ }}" } } }
  ]
}
```

## Example: JSON for the next request

With `json` output, the reply is held to a JSON Schema through the provider's structured outputs, so it always parses, and the next step reads its fields. Here the model turns yesterday's failed Stripe payments into a GitHub issue.

```json
{
  "name": "Failed payments to an issue",
  "cron": { "expression": "0 8 * * *", "time_zone": "America/New_York" },
  "nodes": [
    { "id": "subs", "request_template": { "template_id": "<id of stripe: List subscriptions>", "credential_id": "YOUR_STRIPE_CREDENTIAL_ID", "values": { "status": "past_due" } } },
    { "id": "draft", "ai_generate": {
        "provider": "openai", "model": "YOUR_OPENAI_MODEL", "credential_id": "YOUR_OPENAI_KEY_ID",
        "prompt": "Write a GitHub issue for the finance team about these past-due subscriptions: {{ $.data }}",
        "json": { "schema": "{\"type\":\"object\",\"properties\":{\"title\":{\"type\":\"string\"},\"body\":{\"type\":\"string\"},\"labels\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}}}" } } },
    { "id": "issue", "request": {
        "method": "POST", "endpoint": "https://api.github.com/repos/acme/finance/issues", "credential_id": "YOUR_GITHUB_CREDENTIAL_ID",
        "body": "{ \"title\": \"{{ $.title }}\", \"body\": \"{{ $.body }}\", \"labels\": {{ $.labels }} }" } }
  ]
}
```

The server makes the schema strict before sending it: every property is required and no others are allowed. For a field that may be empty, use a type like `["string", "null"]`.

## Example: an agent with tools

`tools` are HTTP calls the model may make. Each one is a request, a request template or an email, with `$` bound to the arguments the model chose, so `{{ $.email }}` below is the email it asks about. The response goes back to the model, not to the next step. A tool that fails (a 404, a timeout) is reported to the model, which carries on.

```json
{ "id": "triage", "ai_generate": {
    "provider": "xai", "model": "YOUR_GROK_MODEL", "credential_id": "YOUR_XAI_KEY_ID",
    "prompt": "For each new ticket, look up the customer and say whether they're on a paid plan. Tickets: {{ $.results }}",
    "text": { "max_chars": 40000 },
    "tools": [{
      "name": "lookup_customer",
      "description": "Find a customer by email. Returns their plan and signup date.",
      "parameters": "{\"type\":\"object\",\"properties\":{\"email\":{\"type\":\"string\",\"description\":\"The customer's email\"}}}",
      "step": { "id": "t1", "request": { "method": "GET", "endpoint": "https://api.acme.com/customers?email={{ $.email }}", "credential_id": "YOUR_ACME_CREDENTIAL_ID" } }
    }] } }
```

## Example: tools from an MCP server

`mcp_servers` gives the model the tools of a remote MCP server (Streamable HTTP), such as [Financial Modeling Prep](/integrations/financial-modeling-prep)'s market data. Each run starts a session, lists the server's tools and offers them as `<name>_<tool>`. The server's credential (a bearer token, a header or a URL parameter) is sent with every request. `tools` limits the model to the ones you name; omit it to offer every tool the server lists.

```json
{ "id": "brief", "ai_generate": {
    "provider": "openai", "model": "YOUR_OPENAI_MODEL", "credential_id": "YOUR_OPENAI_KEY_ID",
    "prompt": "Is anything on my watchlist moving more than 3% today? {{ $.symbols }}",
    "text": { "max_chars": 2000 },
    "mcp_servers": [{ "name": "fmp", "url": "https://financialmodelingprep.com/mcp", "credential_id": "YOUR_FMP_CREDENTIAL_ID", "tools": ["quote", "news"] }] } }
```

`POST /steps/mcp-tools` with `{"server": {…}}` lists a server's tools, the same list the editor's **Load tools** shows. MCP tool calls count toward the 100, and a tool that reports an error goes back to the model like a failed HTTP tool.

## Set up

1. Create an API key with your provider: the [Claude Console](https://platform.claude.com) for Claude, [platform.openai.com](https://platform.openai.com) for OpenAI, or [console.x.ai](https://console.x.ai) for Grok.
2. Save it as a [credential](/docs#credentials) of kind `bearer_token` (the **Anthropic**, **OpenAI** or **xAI** partner in the app). It's encrypted at rest and never shown again.
3. Add an AI step in the job editor. It asks what the next step needs (a Discord message, or JSON for a request) and sets up the output to match. Then write the prompt, add any tools, and use **Try it** to run the step against the previous step's sample before you save.

Agents can do the same with `POST /steps/test`: send `{"node": …, "input": "<the step's $ as JSON>"}` and get back the step's result and output, with nothing recorded.

## Limits

- The whole step has 15 minutes, tool calls included, and each tool call has 30 seconds. Lower `effort` answers faster.
- `effort` is `low`, `medium` or `high`, or empty for the model's default. OpenAI and Grok also take `none`, which turns reasoning off. Newer OpenAI models, such as `gpt-6-luna`, need `none` to call tools.
- The editor lists the models your key can use, as the provider reports them: Anthropic's active models, OpenAI's chat models and xAI's text models. You can also type any model id. `POST /steps/ai-models` with `{"provider": "AI_PROVIDER_OPENAI", "credential_id": "…"}` returns the same list.
- Up to 50 tools and 10 MCP servers (64 tools each), and 100 tool calls per run.
- A tool’s response is cut off at 256 KiB before it goes back to the model.
- Each run records the tool calls with their status codes, and the tokens the provider reported.

### Generate with Claude, OpenAI or Grok: `ai_generate`

**Sends** Your prompt to the provider’s API with your own key (Anthropic Messages, or OpenAI-style chat completions for OpenAI and xAI), and makes the tool calls the model asks for. **Output** for the next step: The reply: a string with `text` output (use `{{ $ }}`), or the parsed object with `json` output (use `{{ $.field }}`).

| Field | Type | Notes |
| --- | --- | --- |
| `provider` | string, required | `anthropic`, `openai` or `xai` (in the proto, `AI_PROVIDER_ANTHROPIC` and so on). |
| `model` | string, required | The provider’s model id, such as `claude-opus-5-5`. |
| `credential_id` | string, required | A `bearer_token` credential holding the provider’s API key, sent as `x-api-key` to Anthropic and as `Authorization: Bearer` to OpenAI and xAI. An `api_key_header` also works for Anthropic. Usage is billed to that account. |
| `system` | string | Instructions: who the reply is for, its tone and format. The rule for the output is added for you. Placeholders allowed. |
| `prompt` | string, required | The task and its data, such as `Summarize: {{ $.results }}`. Lists and objects are filled in as JSON. Placeholders allowed. |
| `text` | object | Set this or `json`. The reply is plain text. `max_chars` is the longest reply the next step takes, such as 2000 for Discord: the model is told, asked once to shorten a longer reply, and the step fails if it is still over. |
| `json` | object | Set this or `text`. The reply is JSON matching `schema`, a JSON Schema object sent as the provider’s structured output, so the next step can use `{{ $.field }}`. Every property is required; use a type like `["string", "null"]` for one that may be empty. |
| `tools` | object[] | Up to 50 HTTP calls the model may make, 100 calls in total per run. Each has a `name`, a `description` for the model, `parameters` (a JSON Schema string for its arguments) and a `step`: a request or built-in step where `$` is the model’s arguments. A failed call is reported to the model, which carries on. |
| `max_tokens` | integer | Per model call, reasoning included. Up to 128,000 (and the model’s own limit); omit for 16,000. |
| `effort` | string | `low`, `medium` or `high`; omit for the model’s default. Lower is faster. |
| `sample_response` | string | Editor only: an example reply for previewing later steps. Never sent. |
| `mcp_servers` | object[] | Up to 10 remote MCP servers (Streamable HTTP) whose tools the model may call, such as Financial Modeling Prep’s at `https://financialmodelingprep.com/mcp`; GET /mcp-server-templates lists the ones cronservices knows. Each run lists the server’s tools and offers them as `<name>_<tool>`; the calls count toward the 100. |

## For AI agents: getting your user's keys

Every service's guide, the credential kinds, errors and rotation are at [/agents/credentials.md](/agents/credentials.md).

1. **Check first.** Call `GET /credentials`. If one is already saved for this service (same `integration_partner_id`, or an obvious name), use its `id` and skip the rest.
2. **Say why.** Tell the user which service needs a key, what the job will do with it, and the least access it needs.
3. **Let the user save it.** Send them to the `?new=` link in the service's section. They paste the secret into cronservices directly, so it never passes through you.
4. **If they paste it to you anyway,** create the credential with the request shown, then forget it: never repeat it, log it, or write it into a file, commit, issue or job.
5. **Find the id** with `GET /credentials` and use it as the step's `credential_id`.
6. **Check it** with the request in the service's section, asking first when it sends something real.
7. **Report** what you saved, by name and last 4 characters (`last4`), never the secret.

### Anthropic API key (Claude)

**Tell the user:**

1. Open https://platform.claude.com.
2. Sign in to the Claude Console.
3. Open **API keys** and choose **Create key**. Name it “cronservices”.
4. Copy the key. It is shown once.
5. Make sure the account has credits under **Billing**; usage is billed there, not by cronservices.

**Looks like:** `sk-ant-…`

**Save it.** Send the user to https://cron.services/settings/credentials?new=anthropic. It opens **Add credential** on this template; they paste the secret and save. Then call `GET /credentials` and take the `id` of the one they just added. If they gave the secret to you instead:

```bash
curl -X POST https://api.cron.services/credentials \
  -H "Authorization: Bearer $CRONSERVICES_API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"Anthropic API key","integration_partner_id":"anthropic","bearer_token":{"token":"<the secret>"}}'
```

The response has the new `id`. Use it as the step's `credential_id`.

**Check it.** List the models the key can use, without spending tokens.

```bash
curl -X POST https://api.cron.services/steps/ai-models \
  -H "Authorization: Bearer $CRONSERVICES_API_KEY" -H "Content-Type: application/json" \
  -d '{"provider":"AI_PROVIDER_ANTHROPIC","credential_id":"<credential id>"}'
```

A list of Claude models. Pick one for the step’s `model`.

**If it fails:**

- 422 on `credential_id`: Anthropic rejected the key. It was revoked, mistyped, or belongs to a workspace without access.
- The step fails with a billing or credit error: the account needs credits.

### OpenAI API key

**Tell the user:**

1. Open https://platform.openai.com/api-keys.
2. Choose **Create new secret key**, name it “cronservices”, and pick the project.
3. Copy the key. It is shown once.
4. Make sure the organization has billing set up; usage is billed there.

**Looks like:** `sk-… (often sk-proj-…)`

**Save it.** Send the user to https://cron.services/settings/credentials?new=openai. It opens **Add credential** on this template; they paste the secret and save. Then call `GET /credentials` and take the `id` of the one they just added. If they gave the secret to you instead:

```bash
curl -X POST https://api.cron.services/credentials \
  -H "Authorization: Bearer $CRONSERVICES_API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"OpenAI API key","integration_partner_id":"openai","bearer_token":{"token":"<the secret>"}}'
```

The response has the new `id`. Use it as the step's `credential_id`.

**Check it.** List the models the key can use, without spending tokens.

```bash
curl -X POST https://api.cron.services/steps/ai-models \
  -H "Authorization: Bearer $CRONSERVICES_API_KEY" -H "Content-Type: application/json" \
  -d '{"provider":"AI_PROVIDER_OPENAI","credential_id":"<credential id>"}'
```

A list of chat models. Pick one for the step’s `model`.

**If it fails:**

- 422 on `credential_id`: OpenAI rejected the key.
- A model is missing from the list: the project’s model access excludes it, or the organization isn’t verified for it.
- 429 from a run: the account’s rate limit or quota is used up.

### xAI API key (Grok)

**Tell the user:**

1. Open https://console.x.ai.
2. Open **API keys** and create a key.
3. Copy the key. It is shown once.
4. Make sure the team has credits.

**Looks like:** `xai-…`

**Save it.** Send the user to https://cron.services/settings/credentials?new=xai. It opens **Add credential** on this template; they paste the secret and save. Then call `GET /credentials` and take the `id` of the one they just added. If they gave the secret to you instead:

```bash
curl -X POST https://api.cron.services/credentials \
  -H "Authorization: Bearer $CRONSERVICES_API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"xAI API key","integration_partner_id":"xai","bearer_token":{"token":"<the secret>"}}'
```

The response has the new `id`. Use it as the step's `credential_id`.

**Check it.** List the models the key can use, without spending tokens.

```bash
curl -X POST https://api.cron.services/steps/ai-models \
  -H "Authorization: Bearer $CRONSERVICES_API_KEY" -H "Content-Type: application/json" \
  -d '{"provider":"AI_PROVIDER_XAI","credential_id":"<credential id>"}'
```

A list of Grok models. Pick one for the step’s `model`.

**If it fails:**

- 422 on `credential_id`: xAI rejected the key.
- The key was created with restricted endpoints or models: create one that allows chat completions.
