Reply Hours
curl --request GET|PUT|DELETE \
--url https://app.tuco.ai/api/goal-bots/{id}/reply-hours \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"days": {},
"days.{day}.start": "<string>",
"days.{day}.end": "<string>",
"override": {}
}
'import requests
url = "https://app.tuco.ai/api/goal-bots/{id}/reply-hours"
payload = {
"days": {},
"days.{day}.start": "<string>",
"days.{day}.end": "<string>",
"override": {}
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.request("GET|PUT|DELETE", url, json=payload, headers=headers)
print(response.text)const options = {
method: 'GET|PUT|DELETE',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
days: {},
'days.{day}.start': '<string>',
'days.{day}.end': '<string>',
override: {}
})
};
fetch('https://app.tuco.ai/api/goal-bots/{id}/reply-hours', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://app.tuco.ai/api/goal-bots/{id}/reply-hours",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "GET|PUT|DELETE",
CURLOPT_POSTFIELDS => json_encode([
'days' => [
],
'days.{day}.start' => '<string>',
'days.{day}.end' => '<string>',
'override' => [
]
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://app.tuco.ai/api/goal-bots/{id}/reply-hours"
payload := strings.NewReader("{\n \"days\": {},\n \"days.{day}.start\": \"<string>\",\n \"days.{day}.end\": \"<string>\",\n \"override\": {}\n}")
req, _ := http.NewRequest("GET|PUT|DELETE", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.customMethod("GET|PUT|DELETE","https://app.tuco.ai/api/goal-bots/{id}/reply-hours")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"days\": {},\n \"days.{day}.start\": \"<string>\",\n \"days.{day}.end\": \"<string>\",\n \"override\": {}\n}")
.asString();require 'uri'
require 'net/http'
class Net::HTTP::Get|put|delete < Net::HTTPRequest
METHOD = 'GET|PUT|DELETE'
REQUEST_HAS_BODY = 'true'
RESPONSE_HAS_BODY = true
end
url = URI("https://app.tuco.ai/api/goal-bots/{id}/reply-hours")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get|put|delete.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"days\": {},\n \"days.{day}.start\": \"<string>\",\n \"days.{day}.end\": \"<string>\",\n \"override\": {}\n}"
response = http.request(request)
puts response.read_bodyGoal Bots
Reply Hours
Read, set and clear the days and times a goal bot is allowed to answer a lead. Outside them the bot sends nothing and a person answers instead.
GET|PUT|DELETE
/
api
/
goal-bots
/
{id}
/
reply-hours
Reply Hours
curl --request GET|PUT|DELETE \
--url https://app.tuco.ai/api/goal-bots/{id}/reply-hours \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"days": {},
"days.{day}.start": "<string>",
"days.{day}.end": "<string>",
"override": {}
}
'import requests
url = "https://app.tuco.ai/api/goal-bots/{id}/reply-hours"
payload = {
"days": {},
"days.{day}.start": "<string>",
"days.{day}.end": "<string>",
"override": {}
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.request("GET|PUT|DELETE", url, json=payload, headers=headers)
print(response.text)const options = {
method: 'GET|PUT|DELETE',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
days: {},
'days.{day}.start': '<string>',
'days.{day}.end': '<string>',
override: {}
})
};
fetch('https://app.tuco.ai/api/goal-bots/{id}/reply-hours', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://app.tuco.ai/api/goal-bots/{id}/reply-hours",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "GET|PUT|DELETE",
CURLOPT_POSTFIELDS => json_encode([
'days' => [
],
'days.{day}.start' => '<string>',
'days.{day}.end' => '<string>',
'override' => [
]
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://app.tuco.ai/api/goal-bots/{id}/reply-hours"
payload := strings.NewReader("{\n \"days\": {},\n \"days.{day}.start\": \"<string>\",\n \"days.{day}.end\": \"<string>\",\n \"override\": {}\n}")
req, _ := http.NewRequest("GET|PUT|DELETE", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.customMethod("GET|PUT|DELETE","https://app.tuco.ai/api/goal-bots/{id}/reply-hours")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"days\": {},\n \"days.{day}.start\": \"<string>\",\n \"days.{day}.end\": \"<string>\",\n \"override\": {}\n}")
.asString();require 'uri'
require 'net/http'
class Net::HTTP::Get|put|delete < Net::HTTPRequest
METHOD = 'GET|PUT|DELETE'
REQUEST_HAS_BODY = 'true'
RESPONSE_HAS_BODY = true
end
url = URI("https://app.tuco.ai/api/goal-bots/{id}/reply-hours")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get|put|delete.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"days\": {},\n \"days.{day}.start\": \"<string>\",\n \"days.{day}.end\": \"<string>\",\n \"override\": {}\n}"
response = http.request(request)
puts response.read_bodyGoal bots and task agents are the same object.
/api/goal-bots/{id}/reply-hours and
/api/tasks/{id}/reply-hours are aliases over one collection and behave identically.PATCH /api/goal-bots/{id}
as replyHours. Prefer this route for anything automated: it sends only the schedule, so a stale
prompt in your payload cannot overwrite one somebody edited in the dashboard while your script
was running.
What happens outside the hours
The bot sends nothing and drafts nothing. The owner still gets told. They receive the ordinary new-reply email and Telegram — the same ones a workspace with no bot at all gets for an inbound. No empty draft card, no handoff alert, nothing bot-shaped. Only the bot’s work stops; the alerting is unchanged.Nothing is queued. The message is not answered later when the window reopens. There is no
catch-up job and no retry. From that point the conversation is manual — a person answers it, or
nobody does.
Authentication
Pass your workspace API key as a Bearer token, or use a Clerk session token.Authorization: Bearer tuco_xxxxxxxxxxxxx
Path parameter
string
required
The bot id. A malformed id returns
400 Invalid id.The schedule object
{
"days": {
"mon": { "start": "09:00", "end": "17:00" },
"sat": { "start": "10:00", "end": "14:00" }
},
"override": null
}
object
A day that is present is a day the bot answers. A day that is absent is a day it does
not. Keys are
mon tue wed thu fri sat sun. An empty object {} means no schedule
at all, so the bot answers always.string
required
"HH:MM", 24-hour.string
required
"HH:MM", 24-hour. Exclusive — 09:00–17:00 answers at 16:59 and does not answer at 17:00.An end earlier than the start is a night shift, not an error. See below.string | null
"on" answers at all times. "off" never answers. null follows days.An override beats the schedule and does not erase it — clear the override and your saved
days apply again. It has no expiry: it holds until somebody changes it, so one set on a
Friday is still set on Monday.Night shifts
Set an end time earlier than the start time and the window runs past midnight. The hours belong to the day the shift starts.20:00–08:00 on mon covers Monday evening
and Tuesday until 08:00 — so a lead writing at 02:00 on Tuesday is answered, because Monday’s
shift has not finished, and one writing at 09:00 on Tuesday is not.
{ "days": { "mon": { "start": "20:00", "end": "08:00" } } }
{
"days": {
"mon": { "start": "20:00", "end": "08:00" },
"tue": { "start": "20:00", "end": "08:00" },
"wed": { "start": "20:00", "end": "08:00" },
"thu": { "start": "20:00", "end": "08:00" },
"fri": { "start": "20:00", "end": "08:00" }
}
}
Rules the API enforces
startandendmust differ. The same value for both is refused, because it cannot be told apart from “no time at all” or “the whole day”.- Times are read in the workspace timezone, never the server’s and never the lead’s.
GETtells you which one. - A bad schedule is rejected whole. Nothing is stored, and the message names the day.
GET — read it
curl "https://app.tuco.ai/api/goal-bots/6aa9813105bb2cbf5931ac1f/reply-hours" \
-H "Authorization: Bearer tuco_xxxxxxxxxxxxx"
Success (200 OK)
{
"goalBotId": "6aa9813105bb2cbf5931ac1f",
"name": "Auto Replies",
"replyHours": { "days": { "mon": { "start": "09:00", "end": "17:00" } } },
"timezone": "America/New_York",
"answeringNow": true,
"reason": "inside",
"localTime": "11:02",
"localDay": "mon",
"summary": "Monday, 09:00 to 17:00. Tuesday and Wednesday and Thursday and Friday and Saturday and Sunday, no replies."
}
answeringNow and reason answer the question a script actually has before it does anything, so
you do not have to re-implement the schedule logic yourself.
reason | meaning |
|---|---|
no_schedule | no hours set — the bot answers always |
inside | inside today’s window |
outside | today has a window, and now is not in it |
day_off | the bot does not answer today |
override_on | an override is forcing it to answer |
override_off | an override is forcing it silent |
PUT — set it
PUT replaces the whole schedule. It does not merge — send Monday to Friday and you get
Monday to Friday, with no Saturday left over from last month.
curl -X PUT "https://app.tuco.ai/api/goal-bots/6aa9813105bb2cbf5931ac1f/reply-hours" \
-H "Authorization: Bearer tuco_xxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"days": {
"mon": { "start": "09:00", "end": "17:00" },
"tue": { "start": "09:00", "end": "17:00" },
"wed": { "start": "09:00", "end": "17:00" },
"thu": { "start": "09:00", "end": "17:00" },
"fri": { "start": "09:00", "end": "17:00" }
}
}'
curl -X PUT "https://app.tuco.ai/api/goal-bots/6aa9813105bb2cbf5931ac1f/reply-hours" \
-H "Authorization: Bearer tuco_xxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "days": { "mon": { "start": "09:00", "end": "17:00" } }, "override": "off" }'
{ "replyHours": { ... } } — both read
naturally, so both are accepted.
Success (200 OK)
{
"ok": true,
"goalBotId": "6aa9813105bb2cbf5931ac1f",
"replyHours": { "days": { "mon": { "start": "09:00", "end": "17:00" } } },
"timezone": "America/New_York",
"answeringNow": true,
"reason": "inside",
"summary": "Monday, 09:00 to 17:00. Tuesday and Wednesday and Thursday and Friday and Saturday and Sunday, no replies."
}
DELETE — clear it
The bot goes back to answering at all times.
curl -X DELETE "https://app.tuco.ai/api/goal-bots/6aa9813105bb2cbf5931ac1f/reply-hours" \
-H "Authorization: Bearer tuco_xxxxxxxxxxxxx"
PUT with a body of null or {} does the same thing.
Errors
A bad schedule is rejected whole, and the message names the day so you know which value to fix.{
"error": "Saturday: the start time and the end time are the same. Set an end time that is not the start time.",
"errors": ["Saturday: the start time and the end time are the same. Set an end time that is not the start time."]
}
| Status | When | Body |
|---|---|---|
400 | start equal to end | {{ "error": "Saturday: the start time and the end time are the same. Set an end time that is not the start time." }} |
400 | A time that is not HH:MM | {{ "error": "Monday: the start time must look like 09:00." }} |
400 | A key that is not a day | {{ "error": "\"funday\" is not a day. Use mon, tue, wed, thu, fri, sat or sun." }} |
400 | Malformed {id} | {{ "error": "Invalid id" }} |
401 | Missing or invalid API key | {{ "error": "Unauthorized" }} |
404 | No bot with that id in your workspace | {{ "error": "Goal bot not found" }} |
Gotchas
Hours apply to the bot that ANSWERS, which is not always the bot you set them on. Routing
happens first, hours second. A bot with an empty
includeTags answers every lead before the
default “Auto Replies” bot does — so hours on the default bot do nothing in a workspace that also
has an untagged bot. Check which bot actually answers a lead before you schedule it.- There is no workspace default. Setting hours on one bot does nothing to the others.
- Instruction bots have no reply hours. The older stage engine on
/api/instructionsis not covered, so “the AI stops at 17:00” is only true of goal bots. - A schedule stored with a broken window — backwards, or not
HH:MM— makes the bot answer, not go silent. A bug must never be able to mute a bot that was working.