unique-sdk 2026.42.0.dev0__tar.gz → 2026.42.0.dev2__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 (91) hide show
  1. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/PKG-INFO +1 -1
  2. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/pyproject.toml +1 -1
  3. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/cli.py +127 -3
  4. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/commands/agentic_table_write.py +252 -2
  5. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/commands/mcp.py +54 -35
  6. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/skills/unique-cli-agentic-table/SKILL.md +81 -34
  7. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/README.md +0 -0
  8. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/__init__.py +0 -0
  9. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/_api_requestor.py +0 -0
  10. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/_api_resource.py +0 -0
  11. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/_api_version.py +0 -0
  12. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/_error.py +0 -0
  13. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/_http_client.py +0 -0
  14. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/_list_object.py +0 -0
  15. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/_object_classes.py +0 -0
  16. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/_request_options.py +0 -0
  17. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/_unique_object.py +0 -0
  18. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/_unique_ql.py +0 -0
  19. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/_unique_response.py +0 -0
  20. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/_util.py +0 -0
  21. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/_version.py +0 -0
  22. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/_webhook.py +0 -0
  23. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/__init__.py +0 -0
  24. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_acronyms.py +0 -0
  25. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_agentic_table.py +0 -0
  26. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_analytics_order.py +0 -0
  27. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_benchmarking.py +0 -0
  28. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_briefing.py +0 -0
  29. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_chat_completion.py +0 -0
  30. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_content.py +0 -0
  31. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_context_memory.py +0 -0
  32. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_elicitation.py +0 -0
  33. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_embedding.py +0 -0
  34. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_event.py +0 -0
  35. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_folder.py +0 -0
  36. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_group.py +0 -0
  37. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_integrated.py +0 -0
  38. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_llm_models.py +0 -0
  39. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_mcp.py +0 -0
  40. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_message.py +0 -0
  41. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_message_assessment.py +0 -0
  42. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_message_execution.py +0 -0
  43. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_message_log.py +0 -0
  44. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_message_tool.py +0 -0
  45. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_module.py +0 -0
  46. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  47. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_search.py +0 -0
  48. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_search_string.py +0 -0
  49. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  50. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_space.py +0 -0
  51. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_user.py +0 -0
  52. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/api_resources/_web_search.py +0 -0
  53. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/__init__.py +0 -0
  54. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/__main__.py +0 -0
  55. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/commands/__init__.py +0 -0
  56. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
  57. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/commands/agentic_table.py +0 -0
  58. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/commands/browser.py +0 -0
  59. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/commands/cite_file.py +0 -0
  60. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/commands/elicitation.py +0 -0
  61. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/commands/files.py +0 -0
  62. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/commands/folders.py +0 -0
  63. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/commands/navigation.py +0 -0
  64. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/commands/read.py +0 -0
  65. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  66. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/commands/search.py +0 -0
  67. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/commands/subagent.py +0 -0
  68. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/commands/web_search.py +0 -0
  69. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/commands/web_search_config.py +0 -0
  70. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/config.py +0 -0
  71. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/formatting.py +0 -0
  72. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/identity.py +0 -0
  73. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/metadata_filter.py +0 -0
  74. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/shell.py +0 -0
  75. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
  76. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +0 -0
  77. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
  78. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  79. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +0 -0
  80. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
  81. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/skills/unique-cli-uploaded-search/SKILL.md +0 -0
  82. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
  83. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/state.py +0 -0
  84. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/cli/workspace.py +0 -0
  85. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/utils/analytics_order_run.py +0 -0
  86. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/utils/benchmarking_run.py +0 -0
  87. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/utils/chat_history.py +0 -0
  88. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/utils/chat_in_space.py +0 -0
  89. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/utils/file_io.py +0 -0
  90. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/utils/sources.py +0 -0
  91. {unique_sdk-2026.42.0.dev0 → unique_sdk-2026.42.0.dev2}/unique_sdk/utils/token.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-sdk
3
- Version: 2026.42.0.dev0
3
+ Version: 2026.42.0.dev2
4
4
  Summary:
5
5
  Author: Martin Fadler, Konstantin Krauss, Andreas Hauri
6
6
  Author-email: Martin Fadler <martin.fadler@unique.ch>, Konstantin Krauss <konstantin@unique.ch>, Andreas Hauri <andreas@unique.ch>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_sdk"
3
- version = "2026.42.0.dev0"
3
+ version = "2026.42.0.dev2"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -23,6 +23,7 @@ from unique_sdk.cli.commands.agentic_table_write import (
23
23
  cmd_export,
24
24
  cmd_import,
25
25
  cmd_rerun_row,
26
+ cmd_set_cell,
26
27
  )
27
28
  from unique_sdk.cli.commands.browser import (
28
29
  cmd_browser_action,
@@ -2304,9 +2305,9 @@ Work with Agentic Table (magic table) sheets over the public magic-table API.
2304
2305
  \b
2305
2306
  Reads (get-sheet / get-cell / cell-history / list-exports) are Tier 0: no
2306
2307
  confirmation, no side effects. Writes (create-sheet / import / rerun-row /
2307
- export) build and run a sheet — the full loop of create, populate, run, and
2308
- export, plus re-answering a single row. Every
2309
- call is scoped to the current user/company; sheet-role access (Owner / Can
2308
+ set-cell / export) build and run a sheet — the full loop of create, populate,
2309
+ run, and export — plus re-answering a single row or writing one cell's text.
2310
+ Every call is scoped to the current user/company; sheet-role access (Owner / Can
2310
2311
  manage / Can edit) is enforced server-side and a denial is reported as
2311
2312
  `agentic-table: permission denied`.
2312
2313
 
@@ -2322,6 +2323,7 @@ Write subcommands:
2322
2323
  create-sheet Create a new sheet in a space
2323
2324
  import Import questions/sources (adding questions triggers the run)
2324
2325
  rerun-row Re-run the agent for a single row (import cannot redo a row)
2326
+ set-cell Write text into one cell by row/column order (no run)
2325
2327
  export Generate export artifacts (report / question export)
2326
2328
 
2327
2329
  \b
@@ -2329,6 +2331,7 @@ Examples:
2329
2331
  unique-cli agentic-table get-sheet mt_abc123 --cells --metadata
2330
2332
  unique-cli agentic-table create-sheet asst_123 --name "Vendor DDQ"
2331
2333
  unique-cli agentic-table import mt_abc123 --question-file-id c_q --source-file-id c_src --wait
2334
+ unique-cli agentic-table set-cell mt_abc123 --row 1 --col 2 --text "Fee is 2%."
2332
2335
  unique-cli agentic-table export mt_abc123 --type FULL_REPORT --wait
2333
2336
  """
2334
2337
 
@@ -2710,6 +2713,127 @@ def agentic_table_rerun_row(
2710
2713
  )
2711
2714
 
2712
2715
 
2716
+ @agentic_table.command(name="set-cell")
2717
+ @click.argument("table_id")
2718
+ @click.option(
2719
+ "--row",
2720
+ "row_order",
2721
+ type=click.IntRange(min=0),
2722
+ required=True,
2723
+ help="Row order (0-based; 0 is the header).",
2724
+ )
2725
+ @click.option(
2726
+ "--col",
2727
+ "column_order",
2728
+ type=click.IntRange(min=0),
2729
+ required=True,
2730
+ help="Column order (0-based).",
2731
+ )
2732
+ @click.option(
2733
+ "--text",
2734
+ "text",
2735
+ default=None,
2736
+ help="Cell text (exactly one of --text, --file, --stdin).",
2737
+ )
2738
+ @click.option(
2739
+ "--file",
2740
+ "file",
2741
+ type=click.Path(exists=True, dir_okay=False),
2742
+ default=None,
2743
+ help="Read cell text from a UTF-8 file.",
2744
+ )
2745
+ @click.option(
2746
+ "--stdin",
2747
+ "stdin",
2748
+ is_flag=True,
2749
+ default=False,
2750
+ help="Read cell text from stdin.",
2751
+ )
2752
+ @click.option(
2753
+ "--log-file",
2754
+ "log_file",
2755
+ type=click.Path(exists=True, dir_okay=False),
2756
+ default=None,
2757
+ help="JSON array of log entries (text, actorType, createdAt).",
2758
+ )
2759
+ @click.option(
2760
+ "--log-json",
2761
+ "log_json",
2762
+ default=None,
2763
+ help="Inline JSON array of log entries (text, actorType, createdAt).",
2764
+ )
2765
+ @click.option(
2766
+ "--allow-create",
2767
+ "allow_create",
2768
+ is_flag=True,
2769
+ default=False,
2770
+ help="Allow creating the next row or column (not a gap).",
2771
+ )
2772
+ @click.option(
2773
+ "--force",
2774
+ "force",
2775
+ is_flag=True,
2776
+ default=False,
2777
+ help="Write even while the sheet is PROCESSING.",
2778
+ )
2779
+ @click.option(
2780
+ "--json", "output_json", is_flag=True, default=False, help="Print raw JSON."
2781
+ )
2782
+ @click.pass_context
2783
+ def agentic_table_set_cell(
2784
+ ctx: click.Context,
2785
+ table_id: str,
2786
+ row_order: int,
2787
+ column_order: int,
2788
+ text: str | None,
2789
+ file: str | None,
2790
+ stdin: bool,
2791
+ log_file: str | None,
2792
+ log_json: str | None,
2793
+ allow_create: bool,
2794
+ force: bool,
2795
+ output_json: bool,
2796
+ ) -> None:
2797
+ """Write text into one cell by row and column order.
2798
+
2799
+ \b
2800
+ This is not a run. Pass the cell value you already have; the API returns
2801
+ the updated cell immediately. --row/--col are 0-based like get-cell; row 0
2802
+ is the header and is allowed. A missing row or column is refused unless
2803
+ --allow-create, which adds only the next index. A PROCESSING sheet is
2804
+ refused unless --force. Use
2805
+ rerun-row to have the table agent regenerate an answer from sources.
2806
+
2807
+ \b
2808
+ Provide exactly one of --text, --file, or --stdin. Long or multi-line
2809
+ answers belong in a file or stdin. Optional --log-file / --log-json is a
2810
+ JSON array of {text, actorType, createdAt} with actorType TOOL or ASSISTANT.
2811
+
2812
+ \b
2813
+ Examples:
2814
+ unique-cli agentic-table set-cell mt_abc123 --row 1 --col 2 --text "Fee is 2%."
2815
+ unique-cli agentic-table set-cell mt_abc123 --row 1 --col 2 --file ./answer.md
2816
+ unique-cli agentic-table set-cell mt_abc123 --row 1 --col 2 --stdin < answer.md
2817
+ """
2818
+ emit(
2819
+ cmd_set_cell(
2820
+ LazyState.get(ctx),
2821
+ table_id,
2822
+ row_order=row_order,
2823
+ column_order=column_order,
2824
+ text=text,
2825
+ file=file,
2826
+ stdin=stdin,
2827
+ log_file=log_file,
2828
+ log_json=log_json,
2829
+ allow_create=allow_create,
2830
+ force=force,
2831
+ output_json=output_json,
2832
+ ),
2833
+ is_error=_is_agentic_table_error_output,
2834
+ )
2835
+
2836
+
2713
2837
  @agentic_table.command(name="export")
2714
2838
  @click.argument("table_id")
2715
2839
  @click.option(
@@ -5,6 +5,7 @@ The full-loop write slice over the public magic-table API (``2023-12-06``):
5
5
  - ``create-sheet`` — create an empty sheet in a space.
6
6
  - ``import`` — add questions/sources; adding new questions triggers the agent run.
7
7
  - ``rerun-row`` — re-run the agent for a single row.
8
+ - ``set-cell`` — write text into one cell (no run).
8
9
  - ``export`` — generate export artifacts (report / question export) and list them.
9
10
 
10
11
  Together these let an agent build a sheet, run it, and collect the answers it
@@ -35,14 +36,19 @@ from __future__ import annotations
35
36
 
36
37
  import asyncio
37
38
  import json
39
+ import sys
38
40
  import time
39
- from collections.abc import Awaitable, Callable
40
- from typing import NamedTuple, TypeVar
41
+ from collections.abc import Awaitable, Callable, Mapping
42
+ from datetime import datetime
43
+ from pathlib import Path
44
+ from typing import Literal, NamedTuple, TypeVar, cast
41
45
 
42
46
  from unique_sdk._error import UniqueError
43
47
  from unique_sdk.api_resources._agentic_table import (
44
48
  AgenticTable,
45
49
  AgenticTableSheetState,
50
+ LogDetail,
51
+ LogEntry,
46
52
  MagicTableActionResult,
47
53
  MagicTableArtifact,
48
54
  MagicTableArtifactState,
@@ -57,6 +63,7 @@ from unique_sdk.cli.commands.agentic_table import AGENTIC_TABLE_ERROR_PREFIX
57
63
  from unique_sdk.cli.formatting import (
58
64
  format_agentic_table_action_result,
59
65
  format_agentic_table_artifacts,
66
+ format_agentic_table_cell,
60
67
  format_agentic_table_created_sheet,
61
68
  )
62
69
  from unique_sdk.cli.state import ShellState
@@ -152,6 +159,249 @@ def _rejected(result: MagicTableActionResult, *, action: str) -> str:
152
159
  return f"{AGENTIC_TABLE_ERROR_PREFIX} {action} rejected: {message}"
153
160
 
154
161
 
162
+ # unique-cli is primarily agent-driven: USER/SYSTEM would be spoofable
163
+ # provenance on cell-history, so only ASSISTANT and TOOL are accepted here.
164
+ _LOG_ACTOR_TYPES = frozenset({"ASSISTANT", "TOOL"})
165
+ _LOG_FILE_ERRORS = (OSError, UnicodeDecodeError)
166
+
167
+
168
+ def _parse_log_created_at(raw: object, *, index: int) -> str:
169
+ """Return *raw* if it is an ISO-8601 timestamp string.
170
+
171
+ ``Z`` is accepted as UTC. Non-strings and unparseable values fail locally
172
+ so they never become cell-history labels.
173
+ """
174
+ if not isinstance(raw, str):
175
+ raise ValueError(f"logEntries[{index}] createdAt must be an ISO-8601 string")
176
+ candidate = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
177
+ try:
178
+ datetime.fromisoformat(candidate)
179
+ except ValueError as exc:
180
+ raise ValueError(f"logEntries[{index}] createdAt must be ISO-8601") from exc
181
+ return raw
182
+
183
+
184
+ def _sheet_column_count(sheet: Mapping[str, object]) -> int:
185
+ """Return 1 + max ``columnOrder`` in the sheet cells, or 0 if none."""
186
+ cells = sheet.get("magicTableCells")
187
+ if not isinstance(cells, list):
188
+ return 0
189
+ orders = [
190
+ order
191
+ for cell in cells
192
+ if isinstance(cell, dict)
193
+ for order in [cell.get("columnOrder")]
194
+ if isinstance(order, int)
195
+ ]
196
+ return max(orders) + 1 if orders else 0
197
+
198
+
199
+ def _read_cell_text(
200
+ *,
201
+ text: str | None,
202
+ file: str | None,
203
+ stdin: bool,
204
+ ) -> str:
205
+ """Return cell text from exactly one of ``text``, ``file``, or stdin.
206
+
207
+ Raises ``ValueError`` with an unprefixed message when the sources are
208
+ missing, ambiguous, unreadable, empty, or a TTY. Whitespace-only text is
209
+ kept: the API treats it as non-empty.
210
+ """
211
+ sources = sum([text is not None, file is not None, stdin])
212
+ if sources == 0:
213
+ raise ValueError("cell text is required: pass --text, --file, or --stdin")
214
+ if sources > 1:
215
+ raise ValueError(
216
+ "ambiguous input: provide exactly one of --text, --file, or --stdin"
217
+ )
218
+
219
+ if stdin:
220
+ if sys.stdin.isatty():
221
+ raise ValueError("stdin is a tty: pipe input or use --text / --file")
222
+ body = sys.stdin.read()
223
+ elif file is not None:
224
+ try:
225
+ body = Path(file).read_text(encoding="utf-8")
226
+ except _LOG_FILE_ERRORS as exc:
227
+ raise ValueError(f"could not read --file: {exc}") from exc
228
+ else:
229
+ assert text is not None
230
+ body = text
231
+
232
+ if body == "":
233
+ raise ValueError("cell text is empty")
234
+ return body
235
+
236
+
237
+ def _parse_log_entries(
238
+ *,
239
+ log_file: str | None,
240
+ log_json: str | None,
241
+ ) -> list[LogEntry] | None:
242
+ """Parse optional log entries from a file or an inline JSON array.
243
+
244
+ Raises ``ValueError`` with an unprefixed message when both sources are
245
+ set, JSON is invalid, or a required field is missing. ``None`` means
246
+ omit ``logEntries`` from the request.
247
+ """
248
+ if log_file is not None and log_json is not None:
249
+ raise ValueError(
250
+ "ambiguous logs: provide at most one of --log-file or --log-json"
251
+ )
252
+ raw: str | None
253
+ if log_file is not None:
254
+ try:
255
+ raw = Path(log_file).read_text(encoding="utf-8")
256
+ except _LOG_FILE_ERRORS as exc:
257
+ raise ValueError(f"could not read --log-file: {exc}") from exc
258
+ elif log_json is not None:
259
+ raw = log_json
260
+ else:
261
+ return None
262
+
263
+ try:
264
+ parsed = json.loads(raw)
265
+ except json.JSONDecodeError as exc:
266
+ raise ValueError(f"log entries must be JSON: {exc}") from exc
267
+
268
+ if not isinstance(parsed, list):
269
+ raise ValueError("log entries must be a JSON array")
270
+
271
+ entries: list[LogEntry] = []
272
+ for index, item in enumerate(parsed):
273
+ if not isinstance(item, dict):
274
+ raise ValueError(f"logEntries[{index}] must be an object")
275
+ missing = [key for key in ("text", "actorType", "createdAt") if key not in item]
276
+ if missing:
277
+ raise ValueError(
278
+ f"logEntries[{index}] missing required field(s): {', '.join(missing)}"
279
+ )
280
+ actor_raw = item["actorType"]
281
+ if not isinstance(actor_raw, str) or actor_raw not in _LOG_ACTOR_TYPES:
282
+ raise ValueError(
283
+ f"logEntries[{index}] actorType must be one of "
284
+ f"{', '.join(sorted(_LOG_ACTOR_TYPES))}"
285
+ )
286
+ text_raw = item["text"]
287
+ if not isinstance(text_raw, str):
288
+ raise ValueError(f"logEntries[{index}] text must be a string")
289
+ actor = cast(Literal["USER", "SYSTEM", "ASSISTANT", "TOOL"], actor_raw)
290
+ entry: LogEntry = {
291
+ "text": text_raw,
292
+ "actorType": actor,
293
+ "createdAt": _parse_log_created_at(item["createdAt"], index=index),
294
+ }
295
+ message_id = item.get("messageId")
296
+ if message_id is not None:
297
+ if not isinstance(message_id, str):
298
+ raise ValueError(f"logEntries[{index}] messageId must be a string")
299
+ entry["messageId"] = message_id
300
+ if "details" in item and item["details"] is not None:
301
+ if not isinstance(item["details"], dict):
302
+ raise ValueError(f"logEntries[{index}] details must be an object")
303
+ entry["details"] = cast(LogDetail, cast(object, item["details"]))
304
+ entries.append(entry)
305
+ return entries
306
+
307
+
308
+ def cmd_set_cell(
309
+ state: ShellState,
310
+ table_id: str,
311
+ *,
312
+ row_order: int,
313
+ column_order: int,
314
+ text: str | None = None,
315
+ file: str | None = None,
316
+ stdin: bool = False,
317
+ log_file: str | None = None,
318
+ log_json: str | None = None,
319
+ allow_create: bool = False,
320
+ force: bool = False,
321
+ output_json: bool = False,
322
+ ) -> str:
323
+ """Upsert one cell (``POST /magic-table/{id}/cell``).
324
+
325
+ Writes the given text at ``(row_order, column_order)``. This is not a
326
+ run: unlike ``import`` / ``rerun-row`` it does not start the table agent.
327
+ Row 0 (the header) is allowed. ``allow_create`` may add only the next
328
+ row or column; a gap is refused. A ``PROCESSING`` sheet is refused
329
+ unless ``force``.
330
+ """
331
+ try:
332
+ cell_text = _read_cell_text(text=text, file=file, stdin=stdin)
333
+ log_entries = _parse_log_entries(log_file=log_file, log_json=log_json)
334
+ except ValueError as exc:
335
+ return f"{AGENTIC_TABLE_ERROR_PREFIX} {exc}"
336
+
337
+ params: AgenticTable.SetCell = {
338
+ "tableId": table_id,
339
+ "rowOrder": row_order,
340
+ "columnOrder": column_order,
341
+ "text": cell_text,
342
+ }
343
+ if log_entries is not None:
344
+ params["logEntries"] = log_entries
345
+
346
+ async def _run() -> str:
347
+ # GET + POST in one loop; a second asyncio.run closes the HTTP client.
348
+ sheet = await AgenticTable.get_sheet_data(
349
+ user_id=state.config.user_id,
350
+ company_id=state.config.company_id,
351
+ tableId=table_id,
352
+ includeCells=True,
353
+ includeRowCount=True,
354
+ rowOrders=[0],
355
+ )
356
+ if sheet["state"] == AgenticTableSheetState.PROCESSING and not force:
357
+ return (
358
+ f"{AGENTIC_TABLE_ERROR_PREFIX} sheet is PROCESSING; wait for IDLE "
359
+ "or pass --force"
360
+ )
361
+ row_count = sheet.get("magicTableRowCount")
362
+ if not isinstance(row_count, int):
363
+ row_count = 0
364
+ column_count = _sheet_column_count(sheet)
365
+ if allow_create:
366
+ if row_order > row_count:
367
+ return (
368
+ f"{AGENTIC_TABLE_ERROR_PREFIX} row {row_order} is too far "
369
+ f"(sheet has {row_count} rows); --allow-create only adds the next "
370
+ "row"
371
+ )
372
+ if column_order > column_count:
373
+ return (
374
+ f"{AGENTIC_TABLE_ERROR_PREFIX} col {column_order} is too far "
375
+ f"(sheet has {column_count} columns); --allow-create only adds "
376
+ "the next column"
377
+ )
378
+ else:
379
+ if row_order >= row_count:
380
+ return (
381
+ f"{AGENTIC_TABLE_ERROR_PREFIX} row {row_order} is out of range "
382
+ f"(sheet has {row_count} rows); pass --allow-create to add a row"
383
+ )
384
+ if column_order >= column_count:
385
+ return (
386
+ f"{AGENTIC_TABLE_ERROR_PREFIX} col {column_order} is out of range "
387
+ f"(sheet has {column_count} columns); pass --allow-create to add a "
388
+ "column"
389
+ )
390
+ cell = await AgenticTable.set_cell(
391
+ user_id=state.config.user_id,
392
+ company_id=state.config.company_id,
393
+ **params,
394
+ )
395
+ if output_json:
396
+ return json.dumps(cell, indent=2, default=str)
397
+ return format_agentic_table_cell(cell)
398
+
399
+ try:
400
+ return asyncio.run(_run())
401
+ except UniqueError as exc:
402
+ return _error(exc)
403
+
404
+
155
405
  def cmd_create_sheet(
156
406
  state: ShellState,
157
407
  assistant_id: str,
@@ -31,14 +31,10 @@ _LOGGER = logging.getLogger(__name__)
31
31
  # information (UN-21951). Carries *text only* — no source numbers/markers
32
32
  # (referencing is UN-21285, tracked separately).
33
33
  _MCP_OUTPUT_LOG_RELATIVE_PATH = Path(".unique") / "mcp-output.jsonl"
34
- # Writer-side cap so a single huge/raw tool result cannot bloat the manifest.
35
- # This manifest is the groundedness check's source of truth, so the cap is
36
- # sized against the eval model's context window rather than kept minimal:
37
- # GPT-4o's 128k-token input fits ~400k chars, and the runner bounds the
38
- # combined per-turn payload separately (UN-22309). At the previous 50k, a
39
- # large list result (e.g. a 115k-char Jira search) lost most of its items
40
- # before the judge saw them, flagging well-grounded answers as hallucinations.
41
- _MCP_OUTPUT_TEXT_CHAR_LIMIT = 200_000
34
+ # Runaway guard, four times the largest judge window; the runner trims by tokens.
35
+ _MCP_TEXT_GUARD_CHARS = 16_000_000
36
+ # Bytes of tool text each per-turn manifest may hold, about 16 judge windows.
37
+ _MCP_TURN_TEXT_BUDGET_BYTES = 64_000_000
42
38
 
43
39
  # Per-turn manifest of citable MCP sources, consumed by the runner to stitch
44
40
  # ``[mcpsourceN]`` markers into ``<sup>N</sup>`` footnotes + reference chips
@@ -58,13 +54,6 @@ _MCP_REFS_LOCK_FILENAME = "mcp-refs.lock"
58
54
  # behavior (forward/backward compatible).
59
55
  _MCP_REFS_SEED_FILENAME = "mcp-refs-seed.json"
60
56
  _MCP_SNIPPET_CHAR_LIMIT = 300
61
- # Writer-side cap on the per-item ``text`` recorded in the refs manifest — the
62
- # cited item's underlying text, consumed by the runner's hallucination check to
63
- # ground each ``[mcpsourceN]`` citation on what was actually retrieved
64
- # (UN-22762). Half the flat-output cap (``_MCP_OUTPUT_TEXT_CHAR_LIMIT``): one
65
- # cited item (a page, an issue record) rarely exceeds it, and the eval side
66
- # bounds the combined cited-text payload separately.
67
- _MCP_REF_TEXT_CHAR_LIMIT = 100_000
68
57
 
69
58
  # Keys an MCP tool's JSON result commonly uses for a record's human title.
70
59
  _TITLE_KEYS = ("title", "name", "displayName", "subject", "summary", "key")
@@ -721,30 +710,35 @@ def _item_dedup_key(tool_name: str, item: dict[str, Any]) -> str:
721
710
  one number (identical bodies still merge). NOTE: this intentionally weakens
722
711
  the search-then-fetch text-upgrade merge for title-less items only — titled
723
712
  items still merge by title as before.
724
-
725
- The text is capped at ``_MCP_REF_TEXT_CHAR_LIMIT`` BEFORE hashing — the same
726
- cap the manifest stores under ``text``. Without it, the first call (live,
727
- full-length item text) and a later call rebuilding this key from the
728
- truncated manifest entry would hash to different values, so an oversized
729
- title-less result would be re-assigned a duplicate ``[mcpsourceN]`` instead
730
- of deduping.
731
713
  """
732
714
  title = item.get("title")
733
715
  if isinstance(title, str) and title.strip():
734
716
  return f"title:{tool_name}:{title.strip()}"
735
- capped_text = (item.get("text") or "")[:_MCP_REF_TEXT_CHAR_LIMIT]
736
- text_hash = hashlib.sha256(capped_text.encode("utf-8")).hexdigest()[:12]
717
+ text = (item.get("text") or "")[:_MCP_TEXT_GUARD_CHARS]
718
+ text_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
737
719
  return f"tool:{tool_name}:{text_hash}"
738
720
 
739
721
 
740
722
  def _ref_text(item: dict[str, Any]) -> str | None:
741
- """The item's underlying text for the manifest, capped at
742
- ``_MCP_REF_TEXT_CHAR_LIMIT`` (single write-side cap shared by all
743
- extraction modes)."""
723
+ """The item's underlying text for the manifest, up to the runaway guard."""
744
724
  text = item.get("text")
745
725
  if not isinstance(text, str) or not text:
746
726
  return None
747
- return text[:_MCP_REF_TEXT_CHAR_LIMIT]
727
+ return text[:_MCP_TEXT_GUARD_CHARS]
728
+
729
+
730
+ def _utf8_size(text: str) -> int:
731
+ return len(text.encode("utf-8", errors="surrogatepass"))
732
+
733
+
734
+ def _within_bytes(text: str, room: int) -> str:
735
+ """Longest prefix of ``text`` that fits ``room`` UTF-8 bytes."""
736
+ if room <= 0:
737
+ return ""
738
+ encoded = text.encode("utf-8", errors="surrogatepass")
739
+ if len(encoded) <= room:
740
+ return text
741
+ return encoded[:room].decode("utf-8", errors="ignore")
748
742
 
749
743
 
750
744
  def _annotate_mcp_results_for_citations(
@@ -793,21 +787,30 @@ def _annotate_mcp_results_for_citations(
793
787
  numbers_by_key: dict[str, int] = {}
794
788
  for entry in entries:
795
789
  if isinstance(entry.get("sourceNumber"), int):
796
- stored_tool = entry.get("toolName") or tool_name
797
- numbers_by_key[_item_dedup_key(stored_tool, entry)] = entry[
798
- "sourceNumber"
799
- ]
790
+ stored_key = entry.get("dedupKey")
791
+ if not isinstance(stored_key, str):
792
+ stored_tool = entry.get("toolName") or tool_name
793
+ stored_key = _item_dedup_key(stored_tool, entry)
794
+ numbers_by_key[stored_key] = entry["sourceNumber"]
800
795
  entries_by_number = {
801
796
  entry["sourceNumber"]: entry
802
797
  for entry in entries
803
798
  if isinstance(entry.get("sourceNumber"), int)
804
799
  }
805
800
  needs_rewrite = False
801
+ text_room = _MCP_TURN_TEXT_BUDGET_BYTES - sum(
802
+ _utf8_size(entry["text"])
803
+ for entry in entries
804
+ if isinstance(entry.get("text"), str)
805
+ )
806
806
  for item in items:
807
807
  key = _item_dedup_key(tool_name, item)
808
808
  source_number = numbers_by_key.get(key)
809
809
  if source_number is None:
810
810
  source_number = max(_next_mcp_source_number(entries), seed + 1)
811
+ full_text = _ref_text(item) or ""
812
+ entry_text = _within_bytes(full_text, text_room)
813
+ text_room -= _utf8_size(entry_text)
811
814
  manifest_entry = {
812
815
  "sourceNumber": source_number,
813
816
  "toolName": tool_name,
@@ -815,8 +818,11 @@ def _annotate_mcp_results_for_citations(
815
818
  "title": item.get("title"),
816
819
  "snippet": item.get("snippet"),
817
820
  "details": item.get("details"),
818
- "text": _ref_text(item),
821
+ "text": entry_text or None,
819
822
  }
823
+ # Budget-cut text no longer hashes to the key, so it is kept.
824
+ if entry_text != full_text:
825
+ manifest_entry["dedupKey"] = key
820
826
  item_url = item.get("url")
821
827
  if isinstance(item_url, str) and item_url:
822
828
  manifest_entry["url"] = item_url
@@ -855,7 +861,13 @@ def _annotate_mcp_results_for_citations(
855
861
  stored_len = (
856
862
  len(stored_text) if isinstance(stored_text, str) else 0
857
863
  )
858
- if len(new_text) > stored_len:
864
+ growth = _utf8_size(new_text) - (
865
+ _utf8_size(stored_text)
866
+ if isinstance(stored_text, str)
867
+ else 0
868
+ )
869
+ if len(new_text) > stored_len and growth <= text_room:
870
+ text_room -= growth
859
871
  stored["text"] = new_text
860
872
  needs_rewrite = True
861
873
  annotated.append((source_number, item))
@@ -931,12 +943,19 @@ def _append_mcp_output_manifest(
931
943
  refs_log_path = output_path or workspace_manifest_path(
932
944
  _MCP_OUTPUT_LOG_RELATIVE_PATH
933
945
  )
946
+ recorded = refs_log_path.stat().st_size if refs_log_path.is_file() else 0
947
+ text = _within_bytes(
948
+ text[:_MCP_TEXT_GUARD_CHARS], _MCP_TURN_TEXT_BUDGET_BYTES - recorded
949
+ )
950
+ if not text:
951
+ _LOGGER.warning("mcp: turn output budget spent, row skipped")
952
+ return
934
953
  _append_turn_refs_manifest_entry(
935
954
  refs_log_path,
936
955
  {
937
956
  "toolName": name,
938
957
  "serverName": server_name,
939
- "text": text[:_MCP_OUTPUT_TEXT_CHAR_LIMIT],
958
+ "text": text,
940
959
  },
941
960
  )
942
961
  except (UnsafeRefsLogPathError, OSError) as exc:
@@ -4,11 +4,11 @@ description: >-
4
4
  Read and drive Agentic Table (magic table / due-diligence) sheets through the
5
5
  unique-cli agentic-table command. Use when the user or task involves an
6
6
  Agentic Table: inspecting a sheet's state, a cell's value or lock state, a
7
- cell's edit history or its export artifacts; or running the full loop —
8
- creating a sheet, importing a questionnaire and sources, waiting for the
9
- agent to answer, and exporting the result. Access is enforced per sheet by
10
- the platform and varies from sheet to sheet; a denial is reported as
11
- `agentic-table: permission denied`.
7
+ cell's edit history or its export artifacts; writing text into a specific
8
+ cell; or running the full loop — creating a sheet, importing a questionnaire
9
+ and sources, waiting for the agent to answer, and exporting the result.
10
+ Access is enforced per sheet by the platform and varies from sheet to sheet;
11
+ a denial is reported as `agentic-table: permission denied`.
12
12
  ---
13
13
 
14
14
  # Unique CLI -- Agentic Table
@@ -21,17 +21,19 @@ Commands fall into two groups:
21
21
 
22
22
  - **Read (Tier 0)** — `get-sheet`, `get-cell`, `cell-history`, `list-exports`.
23
23
  Never modify anything, never need confirmation.
24
- - **Write (Tier 1)** — `create-sheet`, `import`, `export`, `rerun-row`. These
25
- create a sheet, add questions and sources, start the agent run, produce
26
- export artifacts, and redo a single answer. None of them prompts for
27
- confirmation, but they do change a shared artifact, so say what you did
28
- afterwards.
29
-
30
- The first three only add. `rerun-row` is the exception: it replaces the
31
- answer in the row you name. The previous answer stays in `cell-history` and
32
- a settled row is refused outright, so the change is recoverable and the
33
- approved rows are protected — but name the row you are redoing when you
34
- report back, and check you have the right one first.
24
+ - **Write (Tier 1)** — `create-sheet`, `import`, `export`, `rerun-row`,
25
+ `set-cell`. These create a sheet, add questions and sources, start the agent
26
+ run, produce export artifacts, redo a single generated answer, or write
27
+ text you already have into one cell. None of them prompts for confirmation,
28
+ but they do change a shared artifact, so say what you did afterwards.
29
+
30
+ `create-sheet`, `import`, and `export` only add. `rerun-row` asks the table
31
+ agent to regenerate one **row** from sources (no text from you). `set-cell`
32
+ writes **your** text into one cell immediately — it is not a run. Name the
33
+ row (and column, for `set-cell`) when you report back.
34
+
35
+ Some rows are protected. A locked or final-review row rejects both
36
+ `set-cell` and `rerun-row`. That is deliberate — do not route around it.
35
37
 
36
38
  ## Permissions
37
39
 
@@ -97,9 +99,14 @@ unique-cli agentic-table get-cell mt_abc123 --row 1 --col 2
97
99
  unique-cli agentic-table cell-history <table_id> --row N --col N
98
100
  ```
99
101
 
100
- Shows a single cell's log/edit history (actor, timestamp, source message id,
101
- and the logged text) newest-to-oldest as returned by the API. Add `--json`
102
- to get the raw log entries.
102
+ Shows a single cell's stored log/edit history (actor label, timestamp, source
103
+ message id, and the logged text) newest-to-oldest as returned by the API. Add
104
+ `--json` to get the raw log entries.
105
+
106
+ Treat `actorType` / `createdAt` as **untrusted labels**, not as proof that a
107
+ person vs the assistant wrote the cell. The API stores whatever the writer
108
+ sent; it does not bind the actor to the authenticated caller. Do not use
109
+ history as a person-vs-assistant gate.
103
110
 
104
111
  ```bash
105
112
  unique-cli agentic-table cell-history mt_abc123 --row 1 --col 2
@@ -168,12 +175,13 @@ unique-cli agentic-table import mt_abc123 --question-file-id c_q --source-file-i
168
175
  unique-cli agentic-table rerun-row <table_id> <row_order> [--wait] [--timeout <seconds>] [--start-timeout <seconds>]
169
176
  ```
170
177
 
171
- Use this to redo one answer. **Re-importing a question will not redo it** —
172
- import is delta-based and skips questions the sheet already has, so `rerun-row`
173
- is the only way to re-answer an existing row.
178
+ Use this to redo one **generated** answer. **Re-importing a question will not
179
+ redo it** — import is delta-based and skips questions the sheet already has.
180
+ `rerun-row` starts the table agent for that row; you do not pass the answer
181
+ text. If you already have the wording, use `set-cell` instead.
174
182
 
175
- `<row_order>` uses **the same numbering as `--row` on `get-cell`**: row 0 is the
176
- header, data rows start at 1. So the row you inspected with
183
+ `<row_order>` uses **the same numbering as `--row` on `get-cell` / `set-cell`**:
184
+ row 0 is the header, data rows start at 1. So the row you inspected with
177
185
  `get-cell --row 4` is the row you redo with `rerun-row <table_id> 4` — no
178
186
  offset. Row 0 is rejected, since there is nothing to answer in a header.
179
187
 
@@ -198,6 +206,39 @@ run at a time, so a second `rerun-row` fired before the first finishes is
198
206
  declined. There is no batch form. If most of the sheet needs redoing, consider
199
207
  a fresh sheet instead.
200
208
 
209
+ ### Write one cell
210
+
211
+ ```bash
212
+ unique-cli agentic-table set-cell <table_id> --row N --col N (--text TEXT | --file PATH | --stdin)
213
+ ```
214
+
215
+ Use this when **you already have the text** — the user gave the wording, you
216
+ copied a cell, or you researched the answer yourself. It writes that one cell
217
+ immediately. It does not start the table agent.
218
+
219
+ `--row` / `--col` are the same 0-based numbers as `get-cell`. Row 0 (the
220
+ header) **can** be set. Find coordinates with `get-sheet --cells` or
221
+ `get-cell` first. A coordinate that does not exist is **refused** unless you
222
+ pass `--allow-create` for the **next** row or column only (`--row 5` on a
223
+ 5-row sheet). A far-off number (`--row 50`) is still refused: the API would
224
+ create a gap.
225
+
226
+ There is no batch form. Several cells means several `set-cell` calls. The
227
+ sheet must be `IDLE`; `PROCESSING` is refused unless you pass `--force` (the
228
+ row-runner can overwrite the cell).
229
+
230
+ Long or multi-line answers: `--file` or `--stdin`, not `--text`. Prefer omitting
231
+ `--log-json` / `--log-file`. If you attach a note, send `{text, actorType,
232
+ createdAt}` with `actorType` `TOOL` or `ASSISTANT` and a current ISO-8601 time.
233
+ `USER` and `SYSTEM` are rejected by the CLI.
234
+
235
+ ```bash
236
+ unique-cli agentic-table set-cell mt_abc123 --row 1 --col 2 --text "The management fee is 2%."
237
+ unique-cli agentic-table set-cell mt_abc123 --row 1 --col 2 --file ./answer.md
238
+ ```
239
+
240
+ After a write, say which row and column you changed.
241
+
201
242
  ### Export answers
202
243
 
203
244
  ```bash
@@ -234,32 +275,38 @@ empty and the rest of the chain running against a sheet that does not exist.
234
275
 
235
276
  To fill in an existing questionnaire from a sheet someone else has already
236
277
  answered, skip the create and import steps: read the answers with
237
- `get-sheet --cells` or `get-cell`, and use `cell-history` if you need to know
238
- whether an answer came from a person or the assistant.
278
+ `get-sheet --cells` or `get-cell`. `cell-history` is a stored log, not a bound
279
+ identity signal — do not treat actor labels as proof a person vs the assistant
280
+ wrote the cell.
239
281
 
240
- If a specific answer looks wrong or incomplete, fix that row with `rerun-row`
241
- and export again, rather than re-importing the question or rebuilding the
242
- sheet.
282
+ If a generated answer looks wrong and you want the table agent to try again,
283
+ fix that row with `rerun-row` and export again — not `set-cell`, and not
284
+ re-import.
243
285
 
244
286
  ## Rules
245
287
 
246
288
  1. Rows and columns are numbered from 0, and row 0 is the header — so the first
247
- question is row 1. This holds for `--row`/`--col` on `get-cell` and
248
- `cell-history` and for `<row_order>` on `rerun-row` alike; the same number
249
- means the same row in every command.
289
+ question is row 1. This holds for `--row`/`--col` on `get-cell`,
290
+ `cell-history`, and `set-cell`, and for `<row_order>` on `rerun-row`; the
291
+ same number means the same row in every command. `set-cell` may write row 0;
292
+ `rerun-row` may not.
250
293
  2. Fetch what you need, not everything. `get-cell` for one value,
251
294
  `get-sheet --cells` for an overview — don't dump a whole sheet unless asked.
295
+ Look up coordinates before `set-cell`; do not guess a column index.
252
296
  3. Use `--wait` when a later step depends on the result, and only then. Without
253
297
  it, `import` and `export` return as soon as the request is accepted, and the
254
- answers or artifacts will not be ready yet.
298
+ answers or artifacts will not be ready yet. `set-cell` has no `--wait`: the
299
+ cell is updated when the command returns.
255
300
  4. Never re-run `import` with the same questions to "retry" — ids and texts
256
301
  already on the sheet are skipped, and a run that is already in flight will
257
302
  reject the call.
258
303
  5. Tell the user what you changed. A sheet is shared, and someone else may be
259
- working in it.
304
+ working in it. For `set-cell`, name the row and column.
260
305
  6. Use `--json` when you need to parse fields programmatically (e.g. reading a
261
306
  `contentId` before downloading an export); use the default formatted output
262
307
  when summarising for a person.
308
+ 7. `set-cell` when you have the text. `rerun-row` when the table agent should
309
+ regenerate from sources. Never both for the same correction.
263
310
 
264
311
  ## Prerequisites
265
312