dailybot-cli 3.27.13__tar.gz → 3.28.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/PKG-INFO +4 -2
  2. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/README.md +3 -1
  3. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/api_client.py +72 -24
  4. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/agent.py +73 -36
  5. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/public_api_helpers.py +72 -0
  6. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/config.py +43 -15
  7. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/display.py +51 -10
  8. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli.egg-info/PKG-INFO +4 -2
  9. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/pyproject.toml +1 -1
  10. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/LICENSE +0 -0
  11. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/__init__.py +0 -0
  12. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/__init__.py +0 -0
  13. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/_attachments.py +0 -0
  14. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/_beta.py +0 -0
  15. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/_briefing.py +0 -0
  16. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/_channels.py +0 -0
  17. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/_destructive.py +0 -0
  18. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/_favorites.py +0 -0
  19. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/_outbound.py +0 -0
  20. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/_paging.py +0 -0
  21. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/_refs.py +0 -0
  22. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/_rollups.py +0 -0
  23. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/_schedule.py +0 -0
  24. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/_sorting.py +0 -0
  25. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/_writes.py +0 -0
  26. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/ask.py +0 -0
  27. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/auth.py +0 -0
  28. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/authoring_helpers.py +0 -0
  29. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/board.py +0 -0
  30. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/channels.py +0 -0
  31. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/chat.py +0 -0
  32. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/checkin.py +0 -0
  33. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/config.py +0 -0
  34. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/conversation.py +0 -0
  35. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/env.py +0 -0
  36. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/featured.py +0 -0
  37. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/form.py +0 -0
  38. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/goal.py +0 -0
  39. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/hook.py +0 -0
  40. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/identity.py +0 -0
  41. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/interactive.py +0 -0
  42. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/interactive_chat.py +0 -0
  43. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/kudos.py +0 -0
  44. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/label.py +0 -0
  45. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/plan.py +0 -0
  46. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/plan_label.py +0 -0
  47. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/plan_views.py +0 -0
  48. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/project.py +0 -0
  49. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/query_options.py +0 -0
  50. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/status.py +0 -0
  51. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/task.py +0 -0
  52. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/tasks.py +0 -0
  53. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/tasks_settings.py +0 -0
  54. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/team.py +0 -0
  55. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/uninstall.py +0 -0
  56. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/update.py +0 -0
  57. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/upgrade.py +0 -0
  58. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/user.py +0 -0
  59. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/user_scoped_actions.py +0 -0
  60. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/version.py +0 -0
  61. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/commands/workflow.py +0 -0
  62. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/install_method.py +0 -0
  63. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/ledger.py +0 -0
  64. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/main.py +0 -0
  65. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/tui/__init__.py +0 -0
  66. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/tui/app.py +0 -0
  67. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/tui/commands.py +0 -0
  68. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/tui/conversation.py +0 -0
  69. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli/tui/intents.py +0 -0
  70. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli.egg-info/SOURCES.txt +0 -0
  71. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli.egg-info/dependency_links.txt +0 -0
  72. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli.egg-info/entry_points.txt +0 -0
  73. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli.egg-info/requires.txt +0 -0
  74. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/dailybot_cli.egg-info/top_level.txt +0 -0
  75. {dailybot_cli-3.27.13 → dailybot_cli-3.28.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dailybot-cli
3
- Version: 3.27.13
3
+ Version: 3.28.0
4
4
  Summary: Dailybot CLI - The command-line bridge between humans and agents. Progress reports, observability, and workflow automation for modern teams across Slack, Google Chat, Discord, MS Teams, and more.
5
5
  Author-email: Dailybot <support@dailybot.com>
6
6
  License: MIT
@@ -799,7 +799,9 @@ dailybot agent register --org-name "My Startup" --agent-name "Claude Code" --ema
799
799
 
800
800
  This creates an organization, generates an API key, and saves it as a profile automatically. Every registered agent gets a **free Dailybot email address** (e.g. `claude-code@mail.dailybot.co`) so it can send and receive messages worldwide — with humans and other agents alike.
801
801
 
802
- The output includes a **claim URL** — share it with your team admin to connect the org to Slack, Google Chat, Discord, Microsoft Teams, or other platforms. The claim URL expires in 30 days.
802
+ The output includes a **claim URL** and a short "Send this to your human" block — share it with your team admin to connect the org to Slack, Google Chat, Discord, Microsoft Teams, or other platforms. The CLI prints the link's expiry date as the server reports it. Whoever claims it must open the link while signed in to Dailybot on the web, and becomes an admin of the agent's organization. Treat the link like a password.
803
+
804
+ The registered API key is least-privilege on every plan, and it keeps working after the org is claimed. It can send agent reports and health, read and mark agent messages, and read the account and check-in status. Other commands, including `dailybot agent email send` and `dailybot agent webhook`, are refused with an upgrade hint. Use a key created in Dailybot, or `dailybot login`, for those. On the free plan, reports are capped per day per organization.
803
805
 
804
806
  ### Agent commands
805
807
 
@@ -760,7 +760,9 @@ dailybot agent register --org-name "My Startup" --agent-name "Claude Code" --ema
760
760
 
761
761
  This creates an organization, generates an API key, and saves it as a profile automatically. Every registered agent gets a **free Dailybot email address** (e.g. `claude-code@mail.dailybot.co`) so it can send and receive messages worldwide — with humans and other agents alike.
762
762
 
763
- The output includes a **claim URL** — share it with your team admin to connect the org to Slack, Google Chat, Discord, Microsoft Teams, or other platforms. The claim URL expires in 30 days.
763
+ The output includes a **claim URL** and a short "Send this to your human" block — share it with your team admin to connect the org to Slack, Google Chat, Discord, Microsoft Teams, or other platforms. The CLI prints the link's expiry date as the server reports it. Whoever claims it must open the link while signed in to Dailybot on the web, and becomes an admin of the agent's organization. Treat the link like a password.
764
+
765
+ The registered API key is least-privilege on every plan, and it keeps working after the org is claimed. It can send agent reports and health, read and mark agent messages, and read the account and check-in status. Other commands, including `dailybot agent email send` and `dailybot agent webhook`, are refused with an upgrade hint. Use a key created in Dailybot, or `dailybot login`, for those. On the free plan, reports are capped per day per organization.
764
766
 
765
767
  ### Agent commands
766
768
 
@@ -1,5 +1,7 @@
1
1
  """HTTP client for Dailybot CLI API endpoints."""
2
2
 
3
+ import functools
4
+ import platform
3
5
  import re
4
6
  import time
5
7
  import unicodedata
@@ -12,6 +14,7 @@ from urllib.parse import quote, urljoin, urlsplit
12
14
 
13
15
  import httpx
14
16
 
17
+ from dailybot_cli import __version__
15
18
  from dailybot_cli.config import (
16
19
  API_KEY_SOURCE_ENV_JSON,
17
20
  get_agent_name,
@@ -20,6 +23,7 @@ from dailybot_cli.config import (
20
23
  get_api_url,
21
24
  get_login_token_for,
22
25
  )
26
+ from dailybot_cli.install_method import detect_install_method
23
27
 
24
28
  _MAX_LIST_PAGES: int = 50 # safety cap for paginated list endpoints
25
29
  LONG_TIMEOUT_SECS: float = 120.0 # AI-processing endpoints (ask, submit_update)
@@ -33,6 +37,33 @@ ATTACHMENT_TRANSFER_TIMEOUT_SECS: float = LONG_TIMEOUT_SECS
33
37
  # never cut off by it. This is the wall-clock ceiling for the whole storage hop.
34
38
  ATTACHMENT_DOWNLOAD_DEADLINE_SECS: float = 300.0
35
39
 
40
+ # Every request names the CLI build. The backend measures which CLI versions are
41
+ # still in use from this header before it changes anything an old client relies on.
42
+ USER_AGENT_PRODUCT: str = "dailybot-cli"
43
+ USER_AGENT_UNKNOWN: str = "unknown"
44
+
45
+
46
+ @functools.lru_cache(maxsize=1)
47
+ def user_agent() -> str:
48
+ """`dailybot-cli/<ver> (python/<x.y>; <os>; <install_method>)`, computed once."""
49
+ try:
50
+ method: str = detect_install_method()
51
+ except Exception:
52
+ method = USER_AGENT_UNKNOWN
53
+ python: str = ".".join(platform.python_version_tuple()[:2])
54
+ os_name: str = platform.system().lower() or USER_AGENT_UNKNOWN
55
+ return f"{USER_AGENT_PRODUCT}/{__version__} (python/{python}; {os_name}; {method})"
56
+
57
+
58
+ def _base_headers() -> dict[str, str]:
59
+ """The headers every Dailybot API request carries, before any credential."""
60
+ return {
61
+ "Content-Type": "application/json",
62
+ "Accept": "application/json",
63
+ "User-Agent": user_agent(),
64
+ }
65
+
66
+
36
67
  # HTTP status codes that trigger the alt-credential auth retry. 401 is the
37
68
  # standards-compliant "credentials rejected" answer; 403 is what many
38
69
  # Django/DRF backends actually send for the same condition (including for
@@ -69,6 +100,9 @@ MAX_PAGE_SIZE: int = 100 # server clamps above this; the client clamps too
69
100
 
70
101
  MAX_RATE_LIMIT_RETRIES: int = 3 # attempts to retry a generic 429 before raising
71
102
  DEFAULT_RETRY_AFTER_SECS: float = 1.0 # backoff floor when a 429 omits Retry-After
103
+ # The registration challenge GET waits out a short 429 on its own; a longer wait is
104
+ # reported to the user instead of leaving the terminal asleep.
105
+ REGISTRATION_MAX_AUTO_WAIT_SECS: float = 10.0
72
106
  # A free-plan daily throttle is NOT transient — retrying can never succeed today.
73
107
  FREE_PLAN_DAILY_LIMIT_CODE: str = "free_plan_daily_limit_exceeded"
74
108
 
@@ -344,6 +378,7 @@ class APIError(Exception):
344
378
  code: str | None = None,
345
379
  retry_after: float | None = None,
346
380
  extra: dict[str, Any] | None = None,
381
+ credential: str | None = None,
347
382
  ) -> None:
348
383
  self.status_code: int = status_code
349
384
  self.detail: str = detail
@@ -352,6 +387,9 @@ class APIError(Exception):
352
387
  # Extra machine-readable context from the error body (e.g. upgrade_url,
353
388
  # required_role / current_role). Never a mutable default arg.
354
389
  self.extra: dict[str, Any] = extra or {}
390
+ # Which credential the refused request carried ("api_key" / "bearer"), when
391
+ # known, so a message about an agent key is never shown to a login session.
392
+ self.credential: str | None = credential
355
393
  super().__init__(f"API error {status_code}: {detail}")
356
394
 
357
395
 
@@ -476,10 +514,7 @@ class DailyBotClient:
476
514
  (``self._prefer_api_key``), the order inverts so the per-repo key
477
515
  wins on the first attempt; the 401/403 retry covers the reverse.
478
516
  """
479
- headers: dict[str, str] = {
480
- "Content-Type": "application/json",
481
- "Accept": "application/json",
482
- }
517
+ headers: dict[str, str] = _base_headers()
483
518
  if authenticated:
484
519
  if self.api_key and (self._prefer_api_key or not self.token):
485
520
  headers["X-API-KEY"] = self.api_key
@@ -497,10 +532,7 @@ class DailyBotClient:
497
532
  that all endpoints behave consistently. The server accepts both on
498
533
  every ``/v1/agent*`` endpoint.
499
534
  """
500
- headers: dict[str, str] = {
501
- "Content-Type": "application/json",
502
- "Accept": "application/json",
503
- }
535
+ headers: dict[str, str] = _base_headers()
504
536
  if self.api_key and (self._prefer_api_key or not self.token):
505
537
  headers["X-API-KEY"] = self.api_key
506
538
  self._agent_auth_mode = "api_key"
@@ -517,10 +549,7 @@ class DailyBotClient:
517
549
  If the primary was Bearer, tries API key; if the primary was API key,
518
550
  tries Bearer. Returns ``None`` when no alternative is available.
519
551
  """
520
- headers: dict[str, str] = {
521
- "Content-Type": "application/json",
522
- "Accept": "application/json",
523
- }
552
+ headers: dict[str, str] = _base_headers()
524
553
  if self._agent_auth_mode == "bearer" and self.api_key:
525
554
  headers["X-API-KEY"] = self.api_key
526
555
  self._agent_auth_mode = "api_key"
@@ -810,6 +839,7 @@ class DailyBotClient:
810
839
  code=code,
811
840
  retry_after=retry_after,
812
841
  extra=extra,
842
+ credential=self._agent_auth_mode,
813
843
  )
814
844
  if response.status_code == 204:
815
845
  return {}
@@ -823,14 +853,20 @@ class DailyBotClient:
823
853
  f"The server returned an unreadable response: {_fallback_detail(response)}"
824
854
  ) from exc
825
855
 
826
- def _send_with_retry(self, send: Callable[[], httpx.Response]) -> httpx.Response:
856
+ def _send_with_retry(
857
+ self,
858
+ send: Callable[[], httpx.Response],
859
+ *,
860
+ max_wait_secs: float | None = None,
861
+ ) -> httpx.Response:
827
862
  """Issue a request via ``send`` with bounded retry on a transient 429.
828
863
 
829
864
  Honors the ``Retry-After`` header and backs off exponentially from it, up
830
865
  to ``MAX_RATE_LIMIT_RETRIES`` retries. A ``free_plan_daily_limit_exceeded``
831
866
  429 is NOT transient — it is returned immediately (no retry) so the caller
832
867
  raises a clear error. Any non-429 response is returned as-is. ``time.sleep``
833
- is referenced through the module so tests can patch it.
868
+ is referenced through the module so tests can patch it. With ``max_wait_secs``,
869
+ a wait longer than that is not slept: the 429 is returned for the caller to report.
834
870
  """
835
871
  attempt: int = 0
836
872
  while True:
@@ -854,7 +890,10 @@ class DailyBotClient:
854
890
  retry_after = float(raw_retry)
855
891
  except ValueError:
856
892
  retry_after = DEFAULT_RETRY_AFTER_SECS
857
- time.sleep(retry_after * (2**attempt))
893
+ wait: float = retry_after * (2**attempt)
894
+ if max_wait_secs is not None and wait > max_wait_secs:
895
+ return response
896
+ time.sleep(wait)
858
897
  attempt += 1
859
898
 
860
899
  def _paginated_get(
@@ -3624,7 +3663,9 @@ class DailyBotClient:
3624
3663
  method,
3625
3664
  url,
3626
3665
  content=data,
3627
- headers=headers,
3666
+ # Only the presign's own headers (they win: a signed upload may pin
3667
+ # them) plus the User-Agent. Never a Dailybot credential.
3668
+ headers={"User-Agent": user_agent(), **headers},
3628
3669
  timeout=ATTACHMENT_TRANSFER_TIMEOUT_SECS,
3629
3670
  follow_redirects=False,
3630
3671
  )
@@ -4145,7 +4186,7 @@ class DailyBotClient:
4145
4186
  with httpx.stream(
4146
4187
  "GET",
4147
4188
  location,
4148
- headers={"Accept-Encoding": "identity"},
4189
+ headers={"Accept-Encoding": "identity", "User-Agent": user_agent()},
4149
4190
  timeout=ATTACHMENT_TRANSFER_TIMEOUT_SECS,
4150
4191
  follow_redirects=False,
4151
4192
  ) as response:
@@ -4839,14 +4880,21 @@ class DailyBotClient:
4839
4880
  # --- Agent registration endpoints ---
4840
4881
 
4841
4882
  def get_registration_challenge(self) -> dict[str, Any]:
4842
- """GET /v1/agent/register/challenge/ — no auth required."""
4843
- response: httpx.Response = self._guard_transport(
4844
- lambda: httpx.get(
4845
- f"{self.api_url}/v1/agent/register/challenge/",
4846
- headers=self._headers(authenticated=False),
4847
- timeout=self.timeout,
4883
+ """GET /v1/agent/register/challenge/ — no auth required.
4884
+
4885
+ A read, so a short 429 is waited out (``Retry-After``). The POST that follows
4886
+ is never retried this way: every attempt consumes its challenge.
4887
+ """
4888
+ response: httpx.Response = self._send_with_retry(
4889
+ lambda: self._guard_transport(
4890
+ lambda: httpx.get(
4891
+ f"{self.api_url}/v1/agent/register/challenge/",
4892
+ headers=self._headers(authenticated=False),
4893
+ timeout=self.timeout,
4894
+ ),
4895
+ method="GET",
4848
4896
  ),
4849
- method="GET",
4897
+ max_wait_secs=REGISTRATION_MAX_AUTO_WAIT_SECS,
4850
4898
  )
4851
4899
  return self._handle_response(response)
4852
4900
 
@@ -9,6 +9,7 @@ import click
9
9
 
10
10
  from dailybot_cli import ledger
11
11
  from dailybot_cli.api_client import APIError, DailyBotClient
12
+ from dailybot_cli.commands.public_api_helpers import agent_error_message
12
13
  from dailybot_cli.config import (
13
14
  RepoEnvError,
14
15
  RepoProfileError,
@@ -80,9 +81,30 @@ def _reset_init_nudge() -> None:
80
81
  _NUDGE_SHOWN = False
81
82
 
82
83
 
83
- # Challenge constants (from backend registration_challenge_service.py)
84
- _CHALLENGE_WORD_COUNT: int = 52
84
+ # Registration challenge (API contract rev 1). It is a compatibility speed bump, not a
85
+ # security control: the answer is the session code times the number of words in the
86
+ # instruction. The CLI counts the words so the server can change the wording; 52 is
87
+ # today's count, kept only as the fallback for an instruction too short to be real.
88
+ _CHALLENGE_LEGACY_WORD_COUNT: int = 52
89
+ _CHALLENGE_MIN_WORDS: int = 5
85
90
  _CHALLENGE_NUMBER_RE: re.Pattern[str] = re.compile(r"session is (\d+)\.")
91
+ _CHALLENGE_UNREADABLE_MESSAGE: str = (
92
+ "Could not read the registration challenge from the server. This CLI may be too old "
93
+ "for it: run `dailybot upgrade`, then try again."
94
+ )
95
+ _CHALLENGE_EXPIRED_CODE: str = "challenge_expired"
96
+ _SCOPE_REFUSAL_CODE: str = "plan_upgrade_required"
97
+ _RATE_LIMITED_MESSAGE: str = "Rate limited."
98
+ _RATE_LIMITED_DEFAULT_HINT: str = "Try again in a few minutes."
99
+
100
+
101
+ def _key_is_refused(exc: APIError) -> bool:
102
+ """True when validating a key proves it is unusable.
103
+
104
+ A 403 ``plan_upgrade_required`` means the key authenticated but the call is out of
105
+ its scope (a self-registered key is least-privilege), so the key itself is valid.
106
+ """
107
+ return exc.status_code in (401, 403) and exc.code != _SCOPE_REFUSAL_CODE
86
108
 
87
109
 
88
110
  def _resolve_agent_context(
@@ -301,7 +323,7 @@ def agent_configure(
301
323
  with console.status("Validating API key..."):
302
324
  client.get_agent_health(agent_name=name)
303
325
  except APIError as e:
304
- if e.status_code in (401, 403):
326
+ if _key_is_refused(e):
305
327
  print_error("API key is invalid or unauthorized.")
306
328
  raise SystemExit(1)
307
329
  # Non-auth error means the key itself is valid
@@ -464,7 +486,7 @@ def _init_personal(questionary: Any) -> None:
464
486
  with console.status("Validating API key..."):
465
487
  client.get_agent_health(agent_name=name)
466
488
  except APIError as exc:
467
- if exc.status_code in (401, 403):
489
+ if _key_is_refused(exc):
468
490
  print_error("API key is invalid or unauthorized. Nothing was saved.")
469
491
  raise SystemExit(1)
470
492
  # Non-auth error → key is valid; the failure is unrelated.
@@ -660,7 +682,7 @@ def agent_update(
660
682
  print_pending_agent_messages(pending)
661
683
  _maybe_show_init_nudge(agent_name)
662
684
  except APIError as e:
663
- print_error(e.detail)
685
+ print_error(agent_error_message(e))
664
686
  raise SystemExit(1)
665
687
 
666
688
 
@@ -715,7 +737,7 @@ def agent_health(
715
737
  )
716
738
  print_agent_health(result)
717
739
  except APIError as e:
718
- print_error(e.detail)
740
+ print_error(agent_error_message(e))
719
741
  raise SystemExit(1)
720
742
 
721
743
 
@@ -755,7 +777,7 @@ def webhook_register(
755
777
  )
756
778
  print_webhook_result(result)
757
779
  except APIError as e:
758
- print_error(e.detail)
780
+ print_error(agent_error_message(e))
759
781
  raise SystemExit(1)
760
782
 
761
783
 
@@ -778,7 +800,7 @@ def webhook_unregister(ctx: click.Context, name: str | None, profile: str | None
778
800
  result: dict[str, Any] = client.unregister_agent_webhook(agent_name=agent_name)
779
801
  print_success(result.get("detail", "Webhook unregistered."))
780
802
  except APIError as e:
781
- print_error(e.detail)
803
+ print_error(agent_error_message(e))
782
804
  raise SystemExit(1)
783
805
 
784
806
 
@@ -844,7 +866,7 @@ def message_send(
844
866
  )
845
867
  print_agent_message_sent(result)
846
868
  except APIError as e:
847
- print_error(e.detail)
869
+ print_error(agent_error_message(e))
848
870
  raise SystemExit(1)
849
871
 
850
872
 
@@ -872,7 +894,7 @@ def message_list(ctx: click.Context, name: str | None, profile: str | None, pend
872
894
  )
873
895
  print_agent_messages(messages)
874
896
  except APIError as e:
875
- print_error(e.detail)
897
+ print_error(agent_error_message(e))
876
898
  raise SystemExit(1)
877
899
 
878
900
 
@@ -898,7 +920,7 @@ def message_claim(ctx: click.Context, message_ids: tuple[str, ...], profile: str
898
920
  count: int = result.get("updated", 0)
899
921
  print_success(f"Marked {count} message(s) as read.")
900
922
  except APIError as e:
901
- print_error(e.detail)
923
+ print_error(agent_error_message(e))
902
924
  raise SystemExit(1)
903
925
 
904
926
 
@@ -925,7 +947,7 @@ def message_claim_all(ctx: click.Context, name: str | None, profile: str | None)
925
947
  )
926
948
  print_success("All pending messages marked as delivered.")
927
949
  except APIError as e:
928
- print_error(e.detail)
950
+ print_error(agent_error_message(e))
929
951
  raise SystemExit(1)
930
952
 
931
953
 
@@ -993,11 +1015,11 @@ def email_send(
993
1015
  )
994
1016
  print_agent_email_sent(result)
995
1017
  except APIError as e:
996
- if e.status_code == 429:
1018
+ if e.status_code == 429 and not e.code:
997
1019
  print_error("Hourly email limit exceeded. Try again later.")
998
1020
  print_info(e.detail)
999
1021
  else:
1000
- print_error(e.detail)
1022
+ print_error(agent_error_message(e))
1001
1023
  raise SystemExit(1)
1002
1024
 
1003
1025
 
@@ -1005,13 +1027,38 @@ def email_send(
1005
1027
 
1006
1028
 
1007
1029
  def _solve_challenge(instruction: str) -> int:
1008
- """Extract random_number from challenge instruction and compute the answer."""
1030
+ """Answer the challenge: the session code times the instruction's word count."""
1009
1031
  match: re.Match[str] | None = _CHALLENGE_NUMBER_RE.search(instruction)
1010
1032
  if not match:
1011
- print_error("Could not parse challenge. Please report this issue.")
1033
+ print_error(_CHALLENGE_UNREADABLE_MESSAGE)
1012
1034
  raise SystemExit(1)
1013
1035
  random_number: int = int(match.group(1))
1014
- return random_number * _CHALLENGE_WORD_COUNT
1036
+ word_count: int = len(instruction.split())
1037
+ if word_count < _CHALLENGE_MIN_WORDS:
1038
+ word_count = _CHALLENGE_LEGACY_WORD_COUNT
1039
+ return random_number * word_count
1040
+
1041
+
1042
+ def _is_challenge_expired(exc: APIError) -> bool:
1043
+ """True for an expired challenge. A server older than contract rev 1 sends no code."""
1044
+ if exc.code:
1045
+ return exc.code == _CHALLENGE_EXPIRED_CODE
1046
+ detail: str = exc.detail.lower()
1047
+ return "challenge" in detail and "expired" in detail
1048
+
1049
+
1050
+ def _registration_error_message(exc: APIError) -> str:
1051
+ """What a failed registration tells the user: the code's message, else the detail."""
1052
+ if exc.code:
1053
+ return agent_error_message(exc)
1054
+ if exc.status_code == 429:
1055
+ hint: str = (
1056
+ f"Try again in {int(exc.retry_after)}s."
1057
+ if exc.retry_after
1058
+ else _RATE_LIMITED_DEFAULT_HINT
1059
+ )
1060
+ return f"{_RATE_LIMITED_MESSAGE} {hint}"
1061
+ return exc.detail
1015
1062
 
1016
1063
 
1017
1064
  @agent.command(name="register")
@@ -1058,21 +1105,16 @@ def agent_register(
1058
1105
  )
1059
1106
 
1060
1107
  try:
1061
- result: dict[str, Any] = _attempt_register()
1108
+ try:
1109
+ result: dict[str, Any] = _attempt_register()
1110
+ except APIError as e:
1111
+ if not _is_challenge_expired(e):
1112
+ raise
1113
+ # Every attempt consumes its challenge: retry once with a fresh one.
1114
+ result = _attempt_register()
1062
1115
  except APIError as e:
1063
- if "expired" in e.detail.lower():
1064
- # Auto-retry once with a fresh challenge
1065
- try:
1066
- result = _attempt_register()
1067
- except APIError as retry_e:
1068
- print_error(retry_e.detail)
1069
- raise SystemExit(1)
1070
- elif e.status_code == 429:
1071
- print_error("Rate limited. Try again in a few minutes.")
1072
- raise SystemExit(1)
1073
- else:
1074
- print_error(e.detail)
1075
- raise SystemExit(1)
1116
+ print_error(_registration_error_message(e))
1117
+ raise SystemExit(1)
1076
1118
 
1077
1119
  # Save profile
1078
1120
  api_key: str | None = result.get("api_key")
@@ -1082,8 +1124,3 @@ def agent_register(
1082
1124
  # Display result
1083
1125
  result["profile"] = slug
1084
1126
  print_registration_result(result)
1085
- claim_url: str = result.get("claim_url", "")
1086
- if claim_url:
1087
- print_info(
1088
- "Share the claim URL with your team admin to connect this org to Slack or Google Chat. The URL expires in 30 days."
1089
- )
@@ -961,6 +961,78 @@ def exit_for_tasks_error(
961
961
  raise SystemExit(code)
962
962
 
963
963
 
964
+ # Agent self-registration and claim (API contract rev 1). Scoped to agent commands
965
+ # (looked up only by `agent_error_message`), because generic names such as
966
+ # `missing_fields` or `invalid_answer` could mean something else on another surface.
967
+ # `challenge_expired` is retried once by `agent register` before it reaches the user.
968
+ REGISTRATION_CODE_MESSAGES: dict[str, str] = {
969
+ "missing_fields": "The registration request was missing required fields. Run `dailybot upgrade`, then try again.",
970
+ "invalid_answer": (
971
+ "The registration challenge answer was not a number. This CLI may be too old for "
972
+ "the server: run `dailybot upgrade`, then try again."
973
+ ),
974
+ "challenge_invalid": "The registration challenge was not recognized. Run the command again to get a new one.",
975
+ "challenge_used": (
976
+ "That registration challenge was already used (every attempt consumes it). Run the "
977
+ "command again to get a new one."
978
+ ),
979
+ "challenge_expired": "The registration challenge expired. Run the command again to get a new one.",
980
+ "answer_incorrect": (
981
+ "The registration challenge answer was rejected. This CLI may be too old for the "
982
+ "server: run `dailybot upgrade`, then try again."
983
+ ),
984
+ "reason_too_short": "The registration reason is too short (at least 20 characters).",
985
+ "reason_echoes_instruction": (
986
+ "The registration reason repeats the challenge instruction. Describe why the agent is registering."
987
+ ),
988
+ "registration_rate_limited": "Rate limited: too many registration attempts.",
989
+ "claim_token_unavailable": (
990
+ "No claim link is available: the organization was already claimed or the link "
991
+ "expired. A new link cannot be issued yet."
992
+ ),
993
+ }
994
+
995
+
996
+ # Agent commands run on agent keys, where these two codes mean something narrower than
997
+ # on a person's session (API contract rev 1: a self-registered key is least-privilege
998
+ # on every plan, and an org admin can delete the agent behind it).
999
+ _INVALID_CREDENTIALS_CODE: str = "invalid_credentials"
1000
+ _API_KEY_CREDENTIAL: str = "api_key"
1001
+ AGENT_CODE_MESSAGES: dict[str, str] = {
1002
+ "plan_upgrade_required": (
1003
+ "This credential is not allowed to do that. On the free plan, and with a "
1004
+ "self-registered agent key on any plan, agent commands are limited to agent "
1005
+ "reports, health and messages; `agent email` and `agent webhook` are not included."
1006
+ ),
1007
+ "invalid_credentials": (
1008
+ "This agent key was rejected. An org admin may have deleted the agent, or the "
1009
+ "key belongs to another server (`dailybot env show`). Rotate the key or run "
1010
+ "`dailybot login`."
1011
+ ),
1012
+ }
1013
+
1014
+
1015
+ def agent_error_message(exc: APIError) -> str:
1016
+ """The user-facing message for an agent command's ``APIError``.
1017
+
1018
+ Dispatches on ``code`` (agent-specific copy first, then registration, then the
1019
+ shared map); an unknown or absent code falls back to the server's ``detail``. The
1020
+ agent-key copy for ``invalid_credentials`` is used only when the refused request
1021
+ carried an API key, never for a login session. A 429 gets the wait hint.
1022
+ """
1023
+ code: str | None = exc.code
1024
+ base: str | None = None
1025
+ if code:
1026
+ agent_copy: str | None = AGENT_CODE_MESSAGES.get(code)
1027
+ if code == _INVALID_CREDENTIALS_CODE and exc.credential != _API_KEY_CREDENTIAL:
1028
+ agent_copy = None
1029
+ base = agent_copy or REGISTRATION_CODE_MESSAGES.get(code) or ERROR_CODE_MESSAGES.get(code)
1030
+ message: str = (
1031
+ _augment_code_message(base, code, exc.extra or {}) if base and code else exc.detail
1032
+ )
1033
+ return f"{message}{_throttle_hint(exc, _retry_after_seconds(exc))}"
1034
+
1035
+
964
1036
  def exit_for_api_error(
965
1037
  exc: APIError,
966
1038
  json_mode: bool,
@@ -48,6 +48,8 @@ CREDENTIALS_FILE: Path = CONFIG_DIR / "credentials.json"
48
48
  CONFIG_FILE: Path = CONFIG_DIR / "config.json"
49
49
  ORG_CACHE_FILE: Path = CONFIG_DIR / "org_cache.json"
50
50
  AGENTS_FILE: Path = CONFIG_DIR / "agents.json"
51
+ SECRET_FILE_MODE: int = 0o600
52
+ CONFIG_DIR_MODE: int = 0o700
51
53
 
52
54
 
53
55
  def get_config_dir() -> Path:
@@ -58,10 +60,37 @@ def get_config_dir() -> Path:
58
60
  """
59
61
  env_override: str | None = os.environ.get("DAILYBOT_CONFIG_DIR")
60
62
  path: Path = Path(env_override) if env_override else CONFIG_DIR
61
- path.mkdir(parents=True, exist_ok=True)
63
+ # The mode applies only when the directory is created: an existing one is never
64
+ # loosened (or tightened behind the user's back).
65
+ path.mkdir(mode=CONFIG_DIR_MODE, parents=True, exist_ok=True)
62
66
  return path
63
67
 
64
68
 
69
+ def _tighten_before_write(fd: int) -> None:
70
+ """Set 0o600 on an open descriptor before any secret byte is written.
71
+
72
+ ``os.fchmod`` is POSIX-only (Windows gains it in Python 3.13); there the caller
73
+ falls back to ``os.chmod`` after the write, where POSIX modes are advisory anyway.
74
+ """
75
+ if hasattr(os, "fchmod"):
76
+ os.fchmod(fd, SECRET_FILE_MODE)
77
+
78
+
79
+ def _write_secret_file(path: Path, text: str) -> None:
80
+ """Write a secret store with mode 0o600 from the first byte.
81
+
82
+ A write-then-chmod leaves a umask-permission window while the file already holds
83
+ a token or key. A pre-existing file keeps its old mode through os.open, so the
84
+ descriptor is tightened before the first byte is written.
85
+ """
86
+ fd: int = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, SECRET_FILE_MODE)
87
+ _tighten_before_write(fd)
88
+ with os.fdopen(fd, "w") as handle:
89
+ handle.write(text)
90
+ if not hasattr(os, "fchmod"):
91
+ os.chmod(path, SECRET_FILE_MODE)
92
+
93
+
65
94
  def _credentials_path() -> Path:
66
95
  return get_config_dir() / "credentials.json"
67
96
 
@@ -100,7 +129,8 @@ def save_credentials(
100
129
  """Save credentials to disk."""
101
130
  creds_path: Path = _credentials_path()
102
131
  get_config_dir()
103
- creds_path.write_text(
132
+ _write_secret_file(
133
+ creds_path,
104
134
  json.dumps(
105
135
  {
106
136
  "token": token,
@@ -110,10 +140,8 @@ def save_credentials(
110
140
  "api_url": api_url,
111
141
  },
112
142
  indent=2,
113
- )
143
+ ),
114
144
  )
115
- # Restrict file permissions (owner read/write only)
116
- os.chmod(creds_path, 0o600)
117
145
 
118
146
 
119
147
  def clear_credentials() -> None:
@@ -255,8 +283,7 @@ def save_config(data: dict[str, Any]) -> None:
255
283
  existing[key] = value
256
284
  get_config_dir()
257
285
  config_path: Path = _config_path()
258
- config_path.write_text(json.dumps(existing, indent=2))
259
- os.chmod(config_path, 0o600)
286
+ _write_secret_file(config_path, json.dumps(existing, indent=2))
260
287
 
261
288
 
262
289
  def get_api_key() -> str | None:
@@ -325,7 +352,7 @@ def save_org_cache(email: str, organizations: list[dict[str, Any]]) -> None:
325
352
  """Cache the org list from request_code for UUID resolution in step 2."""
326
353
  get_config_dir()
327
354
  data: dict[str, Any] = {"email": email, "organizations": organizations}
328
- _org_cache_path().write_text(json.dumps(data))
355
+ _write_secret_file(_org_cache_path(), json.dumps(data))
329
356
 
330
357
 
331
358
  def load_org_cache(email: str) -> list[dict[str, Any]] | None:
@@ -375,8 +402,7 @@ def save_org_plan(organization_uuid: str, plan: str | None) -> None:
375
402
  data.pop(organization_uuid, None)
376
403
  else:
377
404
  data[organization_uuid] = plan
378
- cache_path.write_text(json.dumps(data, indent=2))
379
- os.chmod(cache_path, 0o600)
405
+ _write_secret_file(cache_path, json.dumps(data, indent=2))
380
406
 
381
407
 
382
408
  def get_org_plan(organization_uuid: str | None) -> str | None:
@@ -440,8 +466,7 @@ def _save_agents(data: dict[str, Any]) -> None:
440
466
  """Write agents.json with restricted permissions."""
441
467
  get_config_dir()
442
468
  agents_path: Path = _agents_path()
443
- agents_path.write_text(json.dumps(data, indent=2))
444
- os.chmod(agents_path, 0o600)
469
+ _write_secret_file(agents_path, json.dumps(data, indent=2))
445
470
 
446
471
 
447
472
  def save_agent_profile(
@@ -1178,11 +1203,14 @@ def save_repo_env(payload: dict[str, Any], *, cwd: Path | None = None) -> Path:
1178
1203
  serialized: str = json.dumps(_normalize_env_payload(payload), indent=2) + "\n"
1179
1204
  # Create with 0o600 from the very first byte — a plain write-then-chmod
1180
1205
  # leaves a umask-permission window while the file already holds API keys.
1181
- fd: int = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
1206
+ # A pre-existing file keeps its old mode through os.open — tighten the descriptor
1207
+ # before writing.
1208
+ fd: int = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, SECRET_FILE_MODE)
1209
+ _tighten_before_write(fd)
1182
1210
  with os.fdopen(fd, "w") as handle:
1183
1211
  handle.write(serialized)
1184
- # A pre-existing file keeps its old mode through os.open — tighten it.
1185
- os.chmod(env_path, 0o600)
1212
+ if not hasattr(os, "fchmod"):
1213
+ os.chmod(env_path, SECRET_FILE_MODE)
1186
1214
  return env_path
1187
1215
 
1188
1216
 
@@ -6,6 +6,7 @@ tag, so every such message is escaped before interpolation.
6
6
  """
7
7
 
8
8
  import unicodedata
9
+ from datetime import datetime, timezone
9
10
  from typing import Any
10
11
 
11
12
  import click
@@ -766,24 +767,64 @@ def print_env_profiles_table(
766
767
  console.print(table)
767
768
 
768
769
 
770
+ CLAIM_EXPIRY_FALLBACK: str = "See the claim page for when it expires."
771
+
772
+
773
+ def format_claim_expiry(value: Any) -> str:
774
+ """`claim_expires_at` (ISO 8601 UTC) as `YYYY-MM-DD HH:MM UTC`; odd values as sent."""
775
+ if not isinstance(value, str) or not value.strip():
776
+ return ""
777
+ try:
778
+ parsed: datetime = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
779
+ except ValueError:
780
+ return value.strip()
781
+ # The contract sends UTC; a value without an offset is read as UTC, not local time.
782
+ parsed = (
783
+ parsed.replace(tzinfo=timezone.utc)
784
+ if parsed.tzinfo is None
785
+ else parsed.astimezone(timezone.utc)
786
+ )
787
+ return parsed.strftime("%Y-%m-%d %H:%M UTC")
788
+
789
+
769
790
  def print_registration_result(data: dict[str, Any]) -> None:
770
- """Display agent registration result."""
791
+ """Display an agent registration: the panel, then what to send the human owner.
792
+
793
+ The API key is saved to the profile by the caller and never shown here.
794
+ """
771
795
  table: Table = Table(show_header=False, box=None, padding=(0, 2))
772
796
  table.add_column(style="bold")
773
797
  table.add_column(no_wrap=True, overflow="fold")
774
- table.add_row("Agent", data.get("agent_name", ""))
775
- agent_email: str = data.get("agent_email", "")
798
+ table.add_row("Agent", escape(str(data.get("agent_name", ""))))
799
+ agent_email: str = str(data.get("agent_email") or "")
776
800
  if agent_email:
777
- table.add_row("Email", f"[bold]{agent_email}[/bold]")
778
- table.add_row("Org", data.get("org_name", ""))
801
+ table.add_row("Email", f"[bold]{escape(agent_email)}[/bold]")
802
+ table.add_row("Org", escape(str(data.get("org_name", ""))))
779
803
  if data.get("profile"):
780
- table.add_row("Profile", data["profile"])
781
- claim_url: str = data.get("claim_url", "")
804
+ table.add_row("Profile", escape(str(data["profile"])))
805
+ claim_url: str = str(data.get("claim_url") or "")
806
+ expiry: str = format_claim_expiry(data.get("claim_expires_at"))
782
807
  if claim_url:
783
- table.add_row("Claim URL", f"[bold cyan]{claim_url}[/bold cyan]")
808
+ table.add_row("Claim URL", f"[bold cyan]{escape(claim_url)}[/bold cyan]")
809
+ if expiry:
810
+ table.add_row("Claim expires", escape(expiry))
784
811
  console.print(Panel(table, title="[bold]Agent Registered[/bold]", border_style="green"))
785
- if claim_url:
786
- console.print(f"\nClaim URL: {claim_url}")
812
+ if not claim_url:
813
+ return
814
+ expires_line: str = f"The link expires on {expiry}." if expiry else CLAIM_EXPIRY_FALLBACK
815
+ console.print(
816
+ "\n[bold]Send this to your human[/bold] (the person who will own this organization):\n"
817
+ )
818
+ console.print(f"Claim URL: [bold cyan]{escape(claim_url)}[/bold cyan]")
819
+ console.print(
820
+ "Open it while signed in to Dailybot on the web to become an admin of this "
821
+ "organization, then connect it to Slack, Microsoft Teams, Discord or Google Chat. "
822
+ f"{escape(expires_line)}"
823
+ )
824
+ console.print(
825
+ "[dim]Treat the link like a password: anyone who opens it signed in can claim "
826
+ "the organization.[/dim]"
827
+ )
787
828
 
788
829
 
789
830
  def print_update_result(data: dict[str, Any]) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dailybot-cli
3
- Version: 3.27.13
3
+ Version: 3.28.0
4
4
  Summary: Dailybot CLI - The command-line bridge between humans and agents. Progress reports, observability, and workflow automation for modern teams across Slack, Google Chat, Discord, MS Teams, and more.
5
5
  Author-email: Dailybot <support@dailybot.com>
6
6
  License: MIT
@@ -799,7 +799,9 @@ dailybot agent register --org-name "My Startup" --agent-name "Claude Code" --ema
799
799
 
800
800
  This creates an organization, generates an API key, and saves it as a profile automatically. Every registered agent gets a **free Dailybot email address** (e.g. `claude-code@mail.dailybot.co`) so it can send and receive messages worldwide — with humans and other agents alike.
801
801
 
802
- The output includes a **claim URL** — share it with your team admin to connect the org to Slack, Google Chat, Discord, Microsoft Teams, or other platforms. The claim URL expires in 30 days.
802
+ The output includes a **claim URL** and a short "Send this to your human" block — share it with your team admin to connect the org to Slack, Google Chat, Discord, Microsoft Teams, or other platforms. The CLI prints the link's expiry date as the server reports it. Whoever claims it must open the link while signed in to Dailybot on the web, and becomes an admin of the agent's organization. Treat the link like a password.
803
+
804
+ The registered API key is least-privilege on every plan, and it keeps working after the org is claimed. It can send agent reports and health, read and mark agent messages, and read the account and check-in status. Other commands, including `dailybot agent email send` and `dailybot agent webhook`, are refused with an upgrade hint. Use a key created in Dailybot, or `dailybot login`, for those. On the free plan, reports are capped per day per organization.
803
805
 
804
806
  ### Agent commands
805
807
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dailybot-cli"
7
- version = "3.27.13"
7
+ version = "3.28.0"
8
8
  description = "Dailybot CLI - The command-line bridge between humans and agents. Progress reports, observability, and workflow automation for modern teams across Slack, Google Chat, Discord, MS Teams, and more."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
File without changes
File without changes