One at a time: ticket concurrency, and the bot you can build with it

Tickets are how a Skapi project runs something on the server side without owning a server: an HTTP endpoint you register from the dashboard, with a condition that decides which calls count and a chain of actions that runs for each one. Since this month a ticket can also answer its caller at once and go on later, which is what lets a ticket keep itself going. One thing was missing from that picture: a way to say "only one of these at a time". It is there now, as two settings on every ticket, Concurrency and When full. This article explains what they do, with pictures, and then builds the thing they make possible: a bot that wakes up once a day, asks a language model for something, posts it somewhere, writes down what it did and goes back to sleep, with no server, no scheduler and no process of yours to keep alive. At the end, the shortest way to get that bot: describe it in one sentence to an AI agent connected to the Skapi MCP server.

A ticket, in one paragraph

A ticket has an address of its own under your project, in three forms: a GET link anyone can open, a POST endpoint a webhook can be pointed at, and a signed-in endpoint a page calls with consumeTicket(). When a call arrives, the ticket's condition is checked first: the HTTP method, headers, body fields, a webhook signature, the signed-in user. Then its actions run in order: post a record, set a user's access group, grant access to a record, send an e-mail or a newsletter, call any HTTP API with one of your Secret Keys, check an answer and capture a value from it, and Respond, which answers the caller now and lets the rest of the chain go on at once or after a wait. Every call is logged on the Tickets page with what came in, what each action did and what went out. The tickets guide has all of it; this article only needs the Respond and the req.

Two new settings

Concurrency is how many calls of the ticket may be in flight at the same time, a whole number from 1 to 100. Blank means unlimited, which is what every ticket did until today, and what every ticket still does unless you set it. In flight means one of two things: the call's chain is running right now, or the call was answered by a Respond and the rest of its chain is still waiting to go on. The wait counts. A ticket with Concurrency 1 whose chain ends in a Respond that goes on in a day is full for that day, on purpose: that is exactly what keeps a repeating ticket from running twice.

When full is what a call gets when every slot is taken. Wait in line ("queue", the default) puts it in the ticket's line, answers it at once with a receipt, and runs its chain when its turn comes, first in, first out. Refuse ("refuse") fails it on the spot with the error code TICKET_BUSY.

The gate: a ticket with Concurrency 2 holds two calls in flight, one running and one answered that goes on in a day; a third call either waits in the line or is refused with TICKET_BUSY, as the ticket's When full setting says1call 12call 23call 3ticketConcurrency 2slot 1: call 1 is runningslot 2: call 2, answered,goes on in a day: slot takenin flight: 2 of 2, fullWhen full:Wait in linecall 3 waits,position 1, and runswhen a slot freesRefusecall 3 gets TICKET_BUSYConcurrency 2: two calls in flight, the ticket is full. The third one waits in theline or is refused, as the ticket's When full says. Nothing else changes for it.

A few things follow from the definitions and are worth saying out loud. A call that fails the ticket's condition, is expired, exhausted or over its per-user limit never takes a slot and never joins the line: what waits has already passed every check. A dry run on the check URL takes no slot either. A call that waits is a consumption: it took one from the remaining count and from the caller's per-user count when it joined the line, the way a call answered by a Respond does. And nothing changes for a ticket without the setting: the gate is not even looked at.

The line

The receipt a waiting caller gets back is the ordinary consumption receipt with two more fields, sent with HTTP status 202 (200 when the ticket's Always 200 is on):

{
  "queued": true,
  "position": 2,
  "consume_id": "VZCOQMb7Nxy",
  "ticket_id": "daily-joke",
  "tkid": "#daily-joke#VZCOQMb7Nxy#...",
  "hash": "..."
}

position is the call's place in the line, 1 being next. A webhook sender sees a 2xx answer and is satisfied; a page that called consumeTicket() resolves with this object and can tell from queued that the work is still to come. One thing a waiting caller cannot have is the answer a Respond in the chain would have composed: the chain has not run yet, and by the time it does the caller is gone. The Respond still runs, and its answer is logged with the run, but it reaches nobody.

Three calls to a ticket with one slot: the first runs at once, the second and third get a receipt with their position the moment they arrive, wait, and run one after the other as the slot freescall 1call 2call 3runsarrives, runs at oncerunsreceipt: position 1runsreceipt: position 2slot freesslot freestimeOne slot. Each caller had its receipt the moment it arrived; the chains ran inarrival order, one at a time, each starting the moment the one before it ended.

When a slot frees, the head of the line starts within a moment, and the run writes its own log row, marked as continuing the receipt's row, so the Tickets page shows the call twice: once when it arrived and once when it ran, with how long it waited in between. A newcomer never jumps the line: while anyone waits, a free slot goes to the head of the line, and the newcomer takes a place behind it. Each run that waited in the line counts as a queued run on the plan's monthly figure, the same figure a Respond that goes on later counts on: 100 a month on the Free plan, 10,000 on Standard, 100,000 on Premium and $1 per 1,000 past that.

A ticket that calls itself

Here is the recipe for a ticket that runs on its own, from the examples page: a Respond that answers at once and goes on after a wait, then the work, then a req to the ticket's own GET link. Open the link once and it goes round forever: each round ends by starting the next one. Switch the ticket off to stop it.

Give that ticket Concurrency 1, and it gains a property it did not have: it keeps its line to itself. The rule is simple. While a self-calling ticket is full, only its own next round may wait in its line; anyone else who calls it is refused with TICKET_BUSY, under Wait in line as under Refuse. The backend tells the two apart without guessing: every call a ticket makes carries a header naming the ticket that made it, so the loop's own call is recognised, and anything else is an outside call.

A self-calling ticket with Concurrency 1: the link is opened once, the Respond answers and goes on in a day, the slot stays taken during the wait, the actions run and the ticket calls its own link, which waits in the line and starts the next round; someone opening the link again meanwhile gets TICKET_BUSYlink openedonce, by youRespondanswers at once,goes on in a daya day passesthe slot staystakenactions runthen it callsits own linkits own call waits in the line and runs next: round two?someone opens the link againTICKET_BUSYA loop started once cannot be started twice: while it runs, only its ownnext round may wait in its line. Everyone else is told the ticket is busy.

Why this rule, rather than letting outside calls wait too? Because of what a second start would mean. Without it, opening the loop's link a second time while it ran would put a second copy of the loop in the line, and the two would take turns forever, each holding the slot for a day. With it, a loop started once cannot be started twice: the second click is told the ticket is busy, and that is the whole story. When full has no say for such a ticket: the loop's own next round is made from inside the round that is running, so it always finds the slot taken, and it always waits; everyone else is always refused. Concurrency 1 is the whole setting.

The bot: one developer joke a day

Now the thing all of this is for. The ticket below posts one developer joke a day to a social account, keeps a copy in your database, and calls itself for tomorrow. The two API hosts are placeholders: put the model's and the network's real endpoints and body shapes in, as their documentation gives them. The two keys never appear in the ticket: they are Secret Keys of the project, named by secretName, and ${CLIENT_SECRET} stands for the key's value when the request is sent.

{
  "ticket_id": "daily-joke",
  "description": "Posts one developer joke a day",
  "condition": { "method": "GET" },
  "concurrency": 1,
  "actions": [
    { "act": "resp", "exe": { "status": 200, "body": { "ok": true }, "resume": "1d" } },
    {
      "act": "req",
      "exe": {
        "method": "POST",
        "url": "https://api.model.example/v1/chat",
        "secretName": "model-key",
        "headers": { "Authorization": "Bearer ${CLIENT_SECRET}", "Content-Type": "application/json" },
        "data": {
          "model": "the-model-you-use",
          "messages": [{ "role": "user", "content": "Tell one short developer joke nobody has heard. Only the joke, no preamble." }]
        },
        "actions": [
          { "act": "cond", "exe": { "response": [{ "key": "body[choices][0][message][content]", "placeholder": "JOKE" }] } }
        ]
      }
    },
    {
      "act": "req",
      "exe": {
        "method": "POST",
        "url": "https://api.social.example/v2/posts",
        "secretName": "social-key",
        "headers": { "Authorization": "Bearer ${CLIENT_SECRET}", "Content-Type": "application/json" },
        "data": { "text": "${placeholder[JOKE]}" },
        "actions": [
          { "act": "cond", "exe": { "response": [{ "key": "status", "operator": "=", "value": 200 }] } }
        ]
      }
    },
    { "act": "pstr", "exe": { "table": { "name": "jokes", "access_group": "admin" }, "data": { "text": "${placeholder[JOKE]}", "posted": "${ticket[timestamp]}" } } },
    { "act": "req", "exe": { "method": "GET", "url": "https://eu73.skapi.dev/tg/eu73kXm2PqA9vLb4/daily-joke" } }
  ]
}

Read it top to bottom and it is one round:

  1. Respond answers whoever called with { "ok": true } and parks the rest of the chain for a day. The slot is taken from this moment until the round is over.
  2. Ask the model. A req to the model's chat endpoint with the model key. Its Then chain holds one Check answer row that reads the joke out of the answer and captures it as JOKE. A row with a placeholder and no value captures what it finds; the path is the model API's own answer shape, so adjust it to the one you use. An answer with no joke at that path (an error answer, say) fails the round right here.
  3. Post it. A req to the network's posting endpoint with the social key, sending ${placeholder[JOKE]} as the post text. A request's status code alone never fails a chain, so the Check answer row under it insists on a 200: a refused post stops the round instead of being recorded as done.
  4. Keep it. A record in the table jokes, visible to admins of the project, with the text and the time.
  5. Call itself. A req to the ticket's own GET link, the one shown on the ticket's page. This call finds the slot taken by the round making it, waits in the line, and starts the moment the round is over, holding the slot for the next day.

One round of the bot: Respond answers now and goes on in a day, a request asks the model and captures the joke, a request posts it, a record keeps it, and a request to the ticket's own link starts the next roundRespondanswers now,goes onin a dayask the modela req with aSecret Key,captures JOKEpost ita req with aSecret Key,sends the jokekeep ita record inthe tablejokescall itselfits own GETlink startsthe next round${placeholder[JOKE]}, captured from the model's answer, flows into the post and the recordOne round. Nothing of it is hosted by you: each round is one run of the ticket,started by the round before it, logged on the Tickets page with everything it did.

Register it, open its GET link once, and that is the whole deployment. The Tickets page shows the ticket as busy from then on, and the log shows every round: the model's answer, the post, the record. A round that fails is logged with the step it failed at, and the loop stops there, because the self-call at the end of the chain never ran: for a bot, stopping is the right default, and the log says why. Add an err chain to the step that worries you (an e-mail to yourself, say) and it tells you as it happens. To pause the bot, switch the ticket off: the round that was waiting is skipped and logged as skipped. To change the prompt, edit the ticket: the round already waiting finishes with the old chain, and every round after it uses the new one.

What it costs: a round is two queued runs on the plan's month, the day's wait and the self-call's place in the line, so a daily bot uses about 60 of the Free plan's 100 a month, plus whatever the model's and the network's APIs charge on their side. Nothing else: no instance, no container, no cron job, no deployment pipeline for a job that runs for a few seconds a day.

Say it in a sentence

A ticket like the one above has a dozen fields to get right, and the shapes of two other companies' APIs to look up. That is work an AI agent is good at, and the Skapi MCP server gives the agent the tools: it can read the project, keep Secret Keys, register tickets with every setting on this page, switch them on and off, and read their logs. Connect any assistant that speaks MCP to your project and say what you want:

Make me a bot that posts one developer joke on my social account every morning.

The agent asks what it needs to know and does not already have: which model API and which key, which network and which posting endpoint, whether the joke should go into a table too. It stores the two keys as Secret Keys, registers the ticket with Concurrency 1, and hands you the GET link to open once. From there it is the same ticket as above, visible on the Tickets page, pausable with the switch, and the agent reads its log when you ask "did it post today?". The same works for anything that fits the shape "every so often, call this, then that": a morning summary of yesterday's orders mailed to you, a weekly cleanup, a price check that posts a record when something changed, a newsletter assembled from an API and sent to a group.

Where to go next

Set a ticket to Concurrency 1, open its link once, and watch its log fill up on its own.

← Back to all articles