gravity-cli 0.1.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 (92) hide show
  1. gravity_cli-0.1.0/.env.example +21 -0
  2. gravity_cli-0.1.0/.github/workflows/ci.yml +70 -0
  3. gravity_cli-0.1.0/.github/workflows/release.yml +110 -0
  4. gravity_cli-0.1.0/.gitignore +12 -0
  5. gravity_cli-0.1.0/AGENT_STANDARD.md +339 -0
  6. gravity_cli-0.1.0/BUILDER_GUIDE.md +735 -0
  7. gravity_cli-0.1.0/LICENSE +202 -0
  8. gravity_cli-0.1.0/NOTICE +4 -0
  9. gravity_cli-0.1.0/PKG-INFO +494 -0
  10. gravity_cli-0.1.0/README.md +477 -0
  11. gravity_cli-0.1.0/TEMPLATES.md +173 -0
  12. gravity_cli-0.1.0/packages/gravity-schema/LICENSE +202 -0
  13. gravity_cli-0.1.0/packages/gravity-schema/NOTICE +4 -0
  14. gravity_cli-0.1.0/packages/gravity-schema/README.md +3 -0
  15. gravity_cli-0.1.0/packages/gravity-schema/gravity_schema/__init__.py +88 -0
  16. gravity_cli-0.1.0/packages/gravity-schema/gravity_schema/__main__.py +6 -0
  17. gravity_cli-0.1.0/packages/gravity-schema/gravity_schema/catalog.py +93 -0
  18. gravity_cli-0.1.0/packages/gravity-schema/gravity_schema/catalog.yaml +221 -0
  19. gravity_cli-0.1.0/packages/gravity-schema/gravity_schema/catalog_imported.yaml +7802 -0
  20. gravity_cli-0.1.0/packages/gravity-schema/gravity_schema/contract.py +87 -0
  21. gravity_cli-0.1.0/packages/gravity-schema/gravity_schema/cron.py +47 -0
  22. gravity_cli-0.1.0/packages/gravity-schema/gravity_schema/errors.py +171 -0
  23. gravity_cli-0.1.0/packages/gravity-schema/gravity_schema/export.py +67 -0
  24. gravity_cli-0.1.0/packages/gravity-schema/gravity_schema/gen_catalog.py +129 -0
  25. gravity_cli-0.1.0/packages/gravity-schema/gravity_schema/manifest.py +627 -0
  26. gravity_cli-0.1.0/packages/gravity-schema/gravity_schema/validation.py +152 -0
  27. gravity_cli-0.1.0/packages/gravity-schema/pyproject.toml +28 -0
  28. gravity_cli-0.1.0/packages/gravity-schema/schema/manifest.schema.json +450 -0
  29. gravity_cli-0.1.0/packages/gravity-schema/tests/conftest.py +47 -0
  30. gravity_cli-0.1.0/packages/gravity-schema/tests/test_catalog_parity.py +56 -0
  31. gravity_cli-0.1.0/packages/gravity-schema/tests/test_cron.py +31 -0
  32. gravity_cli-0.1.0/packages/gravity-schema/tests/test_errors.py +165 -0
  33. gravity_cli-0.1.0/packages/gravity-schema/tests/test_json_schema.py +87 -0
  34. gravity_cli-0.1.0/packages/gravity-schema/tests/test_kind_outputs.py +69 -0
  35. gravity_cli-0.1.0/packages/gravity-schema/tests/test_manifest.py +626 -0
  36. gravity_cli-0.1.0/packages/gravity-schema/tests/test_slots.py +52 -0
  37. gravity_cli-0.1.0/packages/gravity-schema/tests/test_validation.py +172 -0
  38. gravity_cli-0.1.0/pyproject.toml +53 -0
  39. gravity_cli-0.1.0/src/gravity_cli/__init__.py +11 -0
  40. gravity_cli-0.1.0/src/gravity_cli/__main__.py +4 -0
  41. gravity_cli-0.1.0/src/gravity_cli/_runner_script.py +195 -0
  42. gravity_cli-0.1.0/src/gravity_cli/api.py +88 -0
  43. gravity_cli-0.1.0/src/gravity_cli/cli.py +124 -0
  44. gravity_cli-0.1.0/src/gravity_cli/commands/__init__.py +0 -0
  45. gravity_cli-0.1.0/src/gravity_cli/commands/adapt.py +155 -0
  46. gravity_cli-0.1.0/src/gravity_cli/commands/build.py +83 -0
  47. gravity_cli-0.1.0/src/gravity_cli/commands/init.py +78 -0
  48. gravity_cli-0.1.0/src/gravity_cli/commands/login.py +58 -0
  49. gravity_cli-0.1.0/src/gravity_cli/commands/push.py +98 -0
  50. gravity_cli-0.1.0/src/gravity_cli/commands/run.py +281 -0
  51. gravity_cli-0.1.0/src/gravity_cli/commands/runs.py +49 -0
  52. gravity_cli-0.1.0/src/gravity_cli/commands/schedule.py +74 -0
  53. gravity_cli-0.1.0/src/gravity_cli/commands/tools.py +27 -0
  54. gravity_cli-0.1.0/src/gravity_cli/commands/validate.py +195 -0
  55. gravity_cli-0.1.0/src/gravity_cli/config.py +91 -0
  56. gravity_cli-0.1.0/src/gravity_cli/manifest.py +19 -0
  57. gravity_cli-0.1.0/src/gravity_cli/project.py +42 -0
  58. gravity_cli-0.1.0/src/gravity_cli/templates/chat/.gitignore +6 -0
  59. gravity_cli-0.1.0/src/gravity_cli/templates/chat/manifest.yaml +28 -0
  60. gravity_cli-0.1.0/src/gravity_cli/templates/chat/requirements.txt +3 -0
  61. gravity_cli-0.1.0/src/gravity_cli/templates/chat/src/agent.py +80 -0
  62. gravity_cli-0.1.0/src/gravity_cli/templates/deepagent/.gitignore +6 -0
  63. gravity_cli-0.1.0/src/gravity_cli/templates/deepagent/manifest.yaml +28 -0
  64. gravity_cli-0.1.0/src/gravity_cli/templates/deepagent/requirements.txt +4 -0
  65. gravity_cli-0.1.0/src/gravity_cli/templates/deepagent/src/agent.py +54 -0
  66. gravity_cli-0.1.0/src/gravity_cli/templates/minimal/.gitignore +6 -0
  67. gravity_cli-0.1.0/src/gravity_cli/templates/minimal/manifest.yaml +30 -0
  68. gravity_cli-0.1.0/src/gravity_cli/templates/minimal/requirements.txt +3 -0
  69. gravity_cli-0.1.0/src/gravity_cli/templates/minimal/src/agent.py +58 -0
  70. gravity_cli-0.1.0/src/gravity_cli/templates/structured/.gitignore +6 -0
  71. gravity_cli-0.1.0/src/gravity_cli/templates/structured/manifest.yaml +33 -0
  72. gravity_cli-0.1.0/src/gravity_cli/templates/structured/requirements.txt +3 -0
  73. gravity_cli-0.1.0/src/gravity_cli/templates/structured/src/agent.py +15 -0
  74. gravity_cli-0.1.0/src/gravity_cli/templates/structured/src/gateway.py +26 -0
  75. gravity_cli-0.1.0/src/gravity_cli/templates/structured/src/nodes/agent.py +20 -0
  76. gravity_cli-0.1.0/src/gravity_cli/templates/structured/src/prompts.py +6 -0
  77. gravity_cli-0.1.0/src/gravity_cli/templates/structured/src/state.py +10 -0
  78. gravity_cli-0.1.0/src/gravity_cli/timeline.py +254 -0
  79. gravity_cli-0.1.0/starter/manifest.yaml +50 -0
  80. gravity_cli-0.1.0/starter/requirements.txt +3 -0
  81. gravity_cli-0.1.0/starter/src/agent.py +89 -0
  82. gravity_cli-0.1.0/starter/src/summary.py +8 -0
  83. gravity_cli-0.1.0/starter/tests/test_summary.py +11 -0
  84. gravity_cli-0.1.0/tests/test_adapt.py +71 -0
  85. gravity_cli-0.1.0/tests/test_api.py +66 -0
  86. gravity_cli-0.1.0/tests/test_build_push.py +144 -0
  87. gravity_cli-0.1.0/tests/test_manifest_integration.py +130 -0
  88. gravity_cli-0.1.0/tests/test_run.py +344 -0
  89. gravity_cli-0.1.0/tests/test_templates.py +118 -0
  90. gravity_cli-0.1.0/tests/test_timeline.py +86 -0
  91. gravity_cli-0.1.0/tests/test_welcome.py +25 -0
  92. gravity_cli-0.1.0/uv.lock +699 -0
@@ -0,0 +1,21 @@
1
+ # Copy to ~/.gravity/.env (Windows: %USERPROFILE%\.gravity\.env) and fill in.
2
+ # Every gravity command loads that file. Never commit the filled-in copy.
3
+ # What each variable does: README.md, "Environment variables".
4
+
5
+ # --- gravity run / schedule / adapt -------------------------------------
6
+ # Your gateway dev token. Required to run an agent. A local gateway prints
7
+ # one at startup, or uses its LOCAL_DEV_TOKEN.
8
+ GRAVITY_DEV_TOKEN=
9
+
10
+ # The gateway's MCP endpoint. Leave unset for a local gateway on port 8000.
11
+ # GRAVITY_GATEWAY_URL=https://<hosted gateway host>/mcp
12
+
13
+ # The model endpoint. Leave unset: it follows the gateway (<gateway>/v1).
14
+ # GRAVITY_LLM_URL=
15
+
16
+ # --- push / tools / agents / runs / logs / whoami ------------------------
17
+ # The control plane. Leave unset for a local one at http://localhost:3010.
18
+ # GRAVITY_API_URL=https://<control plane host>
19
+
20
+ # Development only: the control plane's dev token, instead of `gravity login`.
21
+ # GRAVITY_API_TOKEN=
@@ -0,0 +1,70 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_call: # release.yml runs the same checks before publishing
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ concurrency:
13
+ group: ci-${{ github.ref }}
14
+ cancel-in-progress: true
15
+
16
+ jobs:
17
+ lint:
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: astral-sh/setup-uv@v6
22
+ with:
23
+ enable-cache: true
24
+ - run: uv sync --locked # fails if uv.lock is out of date with pyproject.toml
25
+ - run: uv run ruff check .
26
+
27
+ test:
28
+ name: test (${{ matrix.os }}, py${{ matrix.python }})
29
+ runs-on: ${{ matrix.os }}
30
+ strategy:
31
+ fail-fast: false
32
+ matrix:
33
+ os: [ubuntu-latest, windows-latest]
34
+ python: ["3.12", "3.13"]
35
+ steps:
36
+ - uses: actions/checkout@v4
37
+ - uses: astral-sh/setup-uv@v6
38
+ with:
39
+ python-version: ${{ matrix.python }}
40
+ enable-cache: true
41
+ - run: uv sync --locked
42
+ - name: CLI tests
43
+ run: uv run pytest
44
+ - name: gravity-schema tests
45
+ working-directory: packages/gravity-schema
46
+ run: uv run pytest
47
+
48
+ package:
49
+ # Build both packages, install them into a clean environment the way a builder
50
+ # would, and use them: catches files missing from the wheel before PyPI does.
51
+ runs-on: ubuntu-latest
52
+ steps:
53
+ - uses: actions/checkout@v4
54
+ - uses: astral-sh/setup-uv@v6
55
+ - run: uv build --all-packages
56
+ - name: Smoke test the built wheels
57
+ env:
58
+ GRAVITY_CONFIG_DIR: ${{ runner.temp }}/gravity-config
59
+ run: |
60
+ uv venv "$RUNNER_TEMP/smoke"
61
+ uv pip install --python "$RUNNER_TEMP/smoke/bin/python" --find-links dist gravity-cli
62
+ cd "$RUNNER_TEMP"
63
+ "$RUNNER_TEMP/smoke/bin/gravity" --help > /dev/null
64
+ "$RUNNER_TEMP/smoke/bin/gravity" init smoke-agent
65
+ "$RUNNER_TEMP/smoke/bin/gravity" validate smoke-agent
66
+ - uses: actions/upload-artifact@v4
67
+ with:
68
+ name: ci-dist
69
+ path: dist/
70
+ if-no-files-found: error
@@ -0,0 +1,110 @@
1
+ name: Release
2
+
3
+ # Push a tag to release both packages at the same version:
4
+ # v0.1.0rc1 -> TestPyPI (any tag containing "rc")
5
+ # v0.1.0 -> PyPI
6
+ # Publishing uses PyPI trusted publishing: no API token is stored anywhere.
7
+
8
+ on:
9
+ push:
10
+ tags: ["v*"]
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ jobs:
16
+ ci:
17
+ uses: ./.github/workflows/ci.yml
18
+
19
+ build:
20
+ needs: ci
21
+ runs-on: ubuntu-latest
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+ - uses: astral-sh/setup-uv@v6
25
+ - name: Tag matches both package versions and the schema pin
26
+ run: |
27
+ python3 - <<'EOF'
28
+ import os, sys, tomllib
29
+ tag = os.environ["GITHUB_REF_NAME"].removeprefix("v")
30
+ cli = tomllib.load(open("pyproject.toml", "rb"))["project"]
31
+ schema = tomllib.load(open("packages/gravity-schema/pyproject.toml", "rb"))["project"]
32
+ pin = next(d for d in cli["dependencies"] if d.startswith("gravity-schema"))
33
+ problems = [
34
+ f"{name} is {version}, the tag says {tag}"
35
+ for name, version in [("gravity-cli", cli["version"]), ("gravity-schema", schema["version"])]
36
+ if version != tag
37
+ ]
38
+ if pin != f"gravity-schema=={tag}":
39
+ problems.append(f"gravity-cli depends on {pin!r}, expected gravity-schema=={tag}")
40
+ for p in problems:
41
+ print(f"::error::{p}")
42
+ sys.exit(1 if problems else 0)
43
+ EOF
44
+ - run: uv build --all-packages
45
+ - uses: actions/upload-artifact@v4
46
+ with:
47
+ name: dist
48
+ path: dist/
49
+ if-no-files-found: error
50
+
51
+ # One job per package: a pending trusted publisher can create only one new project per
52
+ # login, so each package logs in on its own, under its own environment.
53
+ testpypi:
54
+ if: contains(github.ref_name, 'rc')
55
+ needs: build
56
+ strategy:
57
+ matrix:
58
+ include:
59
+ - { package: gravity_schema, environment: testpypi-schema }
60
+ - { package: gravity_cli, environment: testpypi }
61
+ runs-on: ubuntu-latest
62
+ environment: ${{ matrix.environment }}
63
+ permissions:
64
+ id-token: write # trusted publishing
65
+ steps:
66
+ - uses: actions/download-artifact@v4
67
+ with:
68
+ name: dist
69
+ path: dist/
70
+ - run: mkdir upload && mv dist/${{ matrix.package }}-* upload/
71
+ - uses: pypa/gh-action-pypi-publish@release/v1
72
+ with:
73
+ repository-url: https://test.pypi.org/legacy/
74
+ packages-dir: upload/
75
+
76
+ pypi:
77
+ if: ${{ !contains(github.ref_name, 'rc') }}
78
+ needs: build
79
+ strategy:
80
+ matrix:
81
+ include:
82
+ - { package: gravity_schema, environment: pypi-schema }
83
+ - { package: gravity_cli, environment: pypi }
84
+ runs-on: ubuntu-latest
85
+ environment: ${{ matrix.environment }}
86
+ permissions:
87
+ id-token: write # trusted publishing
88
+ steps:
89
+ - uses: actions/download-artifact@v4
90
+ with:
91
+ name: dist
92
+ path: dist/
93
+ - run: mkdir upload && mv dist/${{ matrix.package }}-* upload/
94
+ - uses: pypa/gh-action-pypi-publish@release/v1
95
+ with:
96
+ packages-dir: upload/
97
+
98
+ github-release:
99
+ needs: pypi
100
+ runs-on: ubuntu-latest
101
+ permissions:
102
+ contents: write
103
+ steps:
104
+ - uses: actions/download-artifact@v4
105
+ with:
106
+ name: dist
107
+ path: dist/
108
+ - env:
109
+ GH_TOKEN: ${{ github.token }}
110
+ run: gh release create "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" --generate-notes
@@ -0,0 +1,12 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ .gravity/
10
+ .env
11
+ .env.*
12
+ !.env.example
@@ -0,0 +1,339 @@
1
+ # Agent submission standard — `contract: v1`
2
+
3
+ What a valid agent directory looks like, exactly what ships, and which check
4
+ catches which mistake. Follow this and `gravity build` passes, the deploy
5
+ succeeds, and the runtime loads it. Deviate and the failure happens at a stage
6
+ you can't see — after upload, on our infrastructure.
7
+
8
+ Start from `gravity init <name>`; every template already conforms
9
+ (`minimal`, `structured`, `deepagent`, `chat`).
10
+
11
+ The manifest rules below are enforced by `python/gravity-schema`, the one
12
+ model shared by the CLI, the runner and the server. Where this document and
13
+ that package disagree, the package wins and this document has a bug.
14
+
15
+ ## 1. Layout
16
+
17
+ ```
18
+ my-agent/
19
+ ├── manifest.yaml required the contract (§2)
20
+ ├── requirements.txt required your direct deps — may be empty, must exist
21
+ ├── requirements.lock generated by `gravity validate`/`build`, for the TARGET platform (§5)
22
+ ├── src/ required all your code; the entrypoint lives here
23
+ │ └── agent.py `graph` is defined here (§3)
24
+ └── tests/ optional shipped if present
25
+ ```
26
+
27
+ **Only these are bundled.** `gravity build` zips exactly `manifest.yaml`,
28
+ `requirements.txt`, `requirements.lock`, and the contents of `src/` and
29
+ `tests/` (minus `__pycache__`). Anything else — `.venv/`, `build/`, notebooks,
30
+ a `data/` folder — is not shipped, and code that reads it will fail at runtime.
31
+
32
+ ## 2. `manifest.yaml`
33
+
34
+ ```yaml
35
+ contract: v1 # only value accepted
36
+ name: my-agent # lowercase slug, ≤64 chars — appears in URLs
37
+ version: 1.0.0 # strict semver; bump to republish — a version can't be overwritten
38
+ description: One line. # ≤500 chars
39
+
40
+ runtime:
41
+ framework: langgraph # only value accepted
42
+ python: "3.13" # 3.10–3.13; the lockfile is compiled for it
43
+ entrypoint: src/agent.py:graph # file:object — see §3
44
+ timeout_seconds: 300 # 10–900
45
+
46
+ dependencies:
47
+ lockfile: requirements.lock # leave as-is
48
+
49
+ inputs: # becomes the consumer's form; arrives as state["inputs"]
50
+ - name: query # the key in state["inputs"]
51
+ type: text # text | textarea | number | boolean | static-dropdown | email | url | date | file
52
+ label: "What should I do?" # what the consumer sees
53
+ required: true
54
+ # options: [a, b] # static-dropdown only
55
+ # accept: [pdf, csv] # file only — extensions without the dot
56
+ # default: ... # never on a file input
57
+
58
+ permissions:
59
+ tools: # every tool your code calls, by Gravity name (§4), ≤50
60
+ - example.echo
61
+ models:
62
+ tier: standard # standard | premium
63
+
64
+ resources:
65
+ memory_mb: 512 # 128–4096; a request the platform clamps
66
+
67
+ triggers: # optional — what starts a run
68
+ manual: true # default
69
+ # schedule:
70
+ # cron: "0 9 * * 1" # 5 fields: digits, * / , - only (no @daily, no MON)
71
+ # consumer_can_change: true
72
+
73
+ approvals: # optional — tools that pause the run for the consumer's yes
74
+ required_for: [] # each must also be in permissions.tools
75
+
76
+ # state: # optional — memory across runs, per consumer
77
+ # max_kb: 256 # 1–256; leave out and the agent gets none
78
+ ```
79
+
80
+ **`approvals` is enforced by the gateway.** A listed tool is held until a
81
+ human answers: in `gravity run` you're asked (`Approve? [y/N]`, default no;
82
+ no answer is a no); in the product it's the consumer's approval inbox. Not
83
+ approved, the call returns `APPROVAL_REJECTED` (or `APPROVAL_TIMEOUT` after 4
84
+ minutes) and never runs — handle that and fall back, as `meeting-notes`
85
+ keeps the recap as a draft. Your code can't skip the question: the gateway
86
+ holds the call, not the agent. Hosted, with tools from `gravity.tools()`, the
87
+ run pauses durably instead of holding the call (§8).
88
+
89
+ `triggers` and `state` are part of the contract so a published agent never
90
+ has to change to use them. `gravity schedule` fires `triggers.schedule` locally, and an agent
91
+ that declares `state` gets `state["memory"]` from its last finished run (saved only when a run
92
+ finishes, never over `max_kb`); hosted, EventBridge fires the schedule and the gateway keeps the memory. An agent
93
+ that nothing can start (`manual: false` with no schedule) is rejected.
94
+
95
+ `slots` (optional) declares swappable adapters: `slots.<slot>.adapters.<name>: [tools]`, one file
96
+ per adapter at `src/adapters/<slot>/<name>.py`. A run is granted `permissions.tools` plus only the
97
+ picked adapter's tools; the pick arrives as `state["inputs"][<slot>]`. See `BUILDER_GUIDE.md` §9.
98
+
99
+ The same schema is validated twice — by `gravity validate` on your machine
100
+ and again by the server on push — because an attacker won't use the CLI.
101
+
102
+ ## 3. The entrypoint
103
+
104
+ `runtime.entrypoint` names a file under `src/` and a module-level object in
105
+ it. The runtime accepts two forms:
106
+
107
+ ```python
108
+ async def graph(): # ✓ recommended — an async factory
109
+ ...
110
+ return workflow.compile()
111
+
112
+ graph = asyncio.run(_build()) # ✓ a graph compiled at import
113
+ graph = _build() # ✓ also fine — a coroutine is awaited
114
+ ```
115
+
116
+ **Prefer the factory**, which is what the templates do. The runner calls it
117
+ fresh for every run, on that run's own event loop, after the run's env vars
118
+ are set. Read the env vars *inside* it: helper modules under `src/` are
119
+ imported once and cached, so an env var read at their top level can outlive
120
+ the run it belonged to.
121
+
122
+ A graph compiled at import also works: the platform imports your module on a
123
+ worker thread precisely so `asyncio.run()` gets its own event loop. If you
124
+ ever see *"asyncio.run() cannot be called from a running event loop,"* the
125
+ runner is too old, not your agent.
126
+
127
+ **State.** The runtime invokes `graph.astream({"inputs": {...}}, stream_mode="updates")`
128
+ — the keyword is passed only when your `astream` accepts it, so a hand-rolled
129
+ object with a plain `astream(state)` works too (accepting `**kwargs` is the
130
+ safe habit). The dict is the
131
+ dict is the consumer's form answers keyed by `inputs[].name`. Your state
132
+ therefore needs:
133
+
134
+ ```python
135
+ class State(TypedDict):
136
+ inputs: dict # required — the consumer's form answers
137
+ result: Any # required in the FINAL state — what the consumer gets
138
+ ... # anything else is yours
139
+ ```
140
+
141
+ **Result.** When the graph finishes, `state["result"]` is what the platform
142
+ returns to the consumer. Any JSON-serialisable value — a string, a dict. A
143
+ graph that finishes without setting `result` returns nothing.
144
+
145
+ **Conversation.** Declaring a `messages` key in your state opts the agent
146
+ into conversation: the platform passes the transcript in on every turn and
147
+ stores what comes back. Don't declare it in a one-shot agent — see the
148
+ `chat` template.
149
+
150
+ Optional convention the platform surfaces if present: `source: "live" | "stub"`
151
+ — whether real tool calls happened or a mock answered.
152
+
153
+ ## 4. Imports, tools, models
154
+
155
+ **Imports: absolute, rooted at `src/`.** The runtime puts `src/` on
156
+ `sys.path` and loads your entrypoint as a top-level module. So:
157
+
158
+ ```python
159
+ from tools.scoring import score # ✓ src/tools/scoring.py
160
+ from nodes.agent import build # ✓ src/nodes/agent.py
161
+ from .nodes.agent import build # ✗ relative imports don't resolve
162
+ from src.nodes.agent import build # ✗ src/ is the root, not a package
163
+ ```
164
+
165
+ `gravity run` loads the same way, so a local run is a faithful preview.
166
+
167
+ **Tools** come only through the gateway, over MCP, and only the ones in
168
+ `permissions.tools`:
169
+
170
+ ```python
171
+ client = MultiServerMCPClient({"gravity": {
172
+ "transport": "streamable_http",
173
+ "url": os.environ["GRAVITY_GATEWAY_URL"],
174
+ "headers": {"Authorization": f"Bearer {os.environ['GRAVITY_RUN_TOKEN']}"},
175
+ }})
176
+ tools = await client.get_tools() # exactly permissions.tools, nothing else
177
+ ```
178
+
179
+ Tool names are `<app>.<action>`, lowercase, from `gravity tools` — Gravity's
180
+ names, never a vendor's (`gmail.read`, not `GMAIL_FETCH_EMAILS`). The gateway
181
+ hands them to your model with `_` for `.` (`gmail_read`), because
182
+ OpenAI-family models reject dots in function names. The sandbox allows no
183
+ other egress — `requests.get(...)` to anywhere times out. Declare what you
184
+ need; don't reach around it.
185
+
186
+ **Models** go through the LLM gateway with the same token:
187
+
188
+ ```python
189
+ model = ChatOpenAI(model="openai/gpt-6-luna",
190
+ base_url=os.environ["GRAVITY_LLM_URL"],
191
+ api_key=os.environ["GRAVITY_RUN_TOKEN"])
192
+ ```
193
+
194
+ Those three env vars — `GRAVITY_GATEWAY_URL`, `GRAVITY_LLM_URL`,
195
+ `GRAVITY_RUN_TOKEN` — are the entire Gravity surface, plus the `gravity`
196
+ package for tools on the platform (§8). No `OPENAI_API_KEY`: locally and in
197
+ production, models go through the gateway.
198
+
199
+ ## 5. Dependencies
200
+
201
+ List direct deps in `requirements.txt` with version ranges. `gravity validate`
202
+ compiles `requirements.lock` **for the target** — `aarch64-manylinux2014`,
203
+ Python `runtime.python`, binary wheels only — not for your laptop. The deploy
204
+ installs from the lock and nothing else.
205
+
206
+ Don't hand-edit the lock. Don't compile it yourself; a native compile on
207
+ Windows or macOS pins the wrong wheels and only fails at deploy.
208
+
209
+ ## 6. Local runs
210
+
211
+ `gravity run --input k=v` runs your graph in your own venv against a real
212
+ gateway, exactly as production does: tools over `GRAVITY_GATEWAY_URL`,
213
+ models over `GRAVITY_LLM_URL`, both with `GRAVITY_RUN_TOKEN`. You set a
214
+ different token once, `GRAVITY_DEV_TOKEN`, in `~/.gravity/.env`: each run
215
+ trades it for a run token scoped to your `permissions.tools`, so an
216
+ undeclared tool is refused locally exactly as it will be when hosted. Your
217
+ agent only ever sees the run token. `GRAVITY_GATEWAY_URL` defaults to
218
+ `http://127.0.0.1:8000/mcp` and `GRAVITY_LLM_URL` to the same host's `/v1`.
219
+
220
+ ## 7. What's checked where
221
+
222
+ | Stage | Catches |
223
+ |---|---|
224
+ | `gravity validate` / `build` (local) | manifest schema · entrypoint file + object exist (static) · `requirements.txt` present · lockfile compiles for aarch64 · undeclared network imports (warning) |
225
+ | `gravity push` (server) | manifest re-validated with the same rules · `permissions.tools` all exist in the catalog · version not already published |
226
+ | deploy | every pin in the lock installs on ARM64 |
227
+ | first run | module imports · `graph` resolves to something with `astream()` · state has `inputs` · final state has `result` |
228
+
229
+ The further right a failure lands, the longer you wait to see it. `build`
230
+ passing means everything in the first column is fine; it does not run your
231
+ code. `gravity run` does.
232
+
233
+ ## 8. On the platform: `import gravity`
234
+
235
+ Hosted runs are durable: they pause for approvals, survive a lost session,
236
+ and remember the thread between runs. The platform does that around your
237
+ graph. `gravity` is preinstalled in the image; don't put it in
238
+ `requirements.txt`.
239
+
240
+ **Tools: `await gravity.tools()`** instead of your own `MultiServerMCPClient`.
241
+ The same LangChain tools under the same names (`gmail_read`), from the same env
242
+ vars. Off the platform (`gravity run`, tests) it is exactly the client in §4,
243
+ but only if your venv has it: it stays out of `requirements.txt` and `gravity run`
244
+ doesn't add it, so install it yourself (`uv pip install -e <repo>/code-first/python/gravity-sdk`)
245
+ or `gravity run` fails with `No module named 'gravity'`.
246
+ On the platform, each tool in `approvals.required_for` is wrapped: calling it
247
+ checkpoints the run and stops it until the consumer decides, with no
248
+ connection held open. Approved, the call runs with the arguments the consumer
249
+ saw. Declined, it returns
250
+ `APPROVAL_REJECTED: '<tool>' was not approved, so it did not run`, the same
251
+ text as today, so keep your fallback.
252
+
253
+ ```python
254
+ tools = {t.name: t for t in await gravity.tools()}
255
+ await tools["slack_send"].ainvoke({"channel": cid, "markdown_text": text})
256
+ ```
257
+
258
+ When the run resumes, the node that made the call **runs again from its
259
+ start**: everything before the call happens twice.
260
+
261
+ - Put a gated call at the end of its own node, after nothing expensive or side-effecting.
262
+ - Several gated calls in one node work, each approved in turn. On each resume the
263
+ earlier calls are repeated, and the gateway answers a repeat with the stored result
264
+ instead of doing it again, so each side effect still happens once. Work *between*
265
+ the calls repeats too, so keep it cheap.
266
+ - Two approvals at once: make them parallel nodes. The consumer sees both together.
267
+ - Any `interrupt()` of your own fails the run with `UNSUPPORTED_INTERRUPT` (for now).
268
+
269
+ **Persistence is the platform's.** Compile with no checkpointer and no store
270
+ (`workflow.compile()`); the platform injects its own, and one you pass is
271
+ replaced. State is kept per thread. A key the next run doesn't send keeps its
272
+ value (a monitor's `seen`, a draft). For memory shared by one consumer's
273
+ threads, use `langgraph.config.get_store()` (`aget` / `aput` / `asearch` by
274
+ namespace and filter; no semantic search).
275
+
276
+ **Conversation.** `messages` must use the `add_messages` reducer:
277
+ `messages: Annotated[list, add_messages]`. Each turn the platform sends only
278
+ the new user turn. The history comes from the thread's checkpoint, and a
279
+ plain `list` would replace it with that one message.
280
+
281
+ **`kind` and `outputs`.**
282
+
283
+ ```yaml
284
+ kind: transform # transform | interactive (default) | monitor
285
+ outputs: # what the consumer may ask the result to become
286
+ deck: {renderer: pptx, label: "Pitch deck"}
287
+ readout: {renderer: docx, label: "Read-out document"}
288
+ summary: {renderer: markdown, label: "Summary", destinations: [download, gmail.draft, gmail.send]}
289
+ ```
290
+
291
+ - `transform`: all input at the start, the result at the end. The platform
292
+ delivers it, so the agent needs no write tools.
293
+ - `interactive`: calls tools mid-flow and may converse over several turns.
294
+ - `monitor`: runs on a schedule or a webhook, on one thread, remembering the
295
+ last run. It needs `triggers.schedule` or a webhook.
296
+
297
+ `destinations` is where an output may go: `download` (the default),
298
+ `gmail.draft` or `gmail.send`. With `gmail.send` the consumer decides when:
299
+ on the Start or Schedule form, or by asking in chat and confirming. A
300
+ scheduled agent can email every run after that one confirm. The email goes
301
+ to one address, with the output's content in the message body and no
302
+ attachment; a thread sends at most 200 a day. The platform sends it, not the
303
+ agent, so the agent needs no Gmail tool and no approval for it.
304
+
305
+ Renderers: `markdown`, `docx`, `pptx`, `rows` (the first table, as CSV). Each
306
+ consumer picks outputs and destinations per run. The agent writes one
307
+ **corpus** into its final state, and every output is rendered from it:
308
+
309
+ ```python
310
+ corpus = gravity.Corpus(
311
+ title="Acme: seed round", subtitle="2026", summary="**Why now**: ...",
312
+ sections=[gravity.Section(
313
+ heading="Market", body="markdown", bullets=["..."],
314
+ facts=[gravity.Fact(label="TAM", value="$18B", source="s1")], # value: text or a number
315
+ table=gravity.Table(columns=["Segment", "Teams"], rows=[["Founders", "1.2M"]]),
316
+ notes="speaker notes / read-out text",
317
+ sections=[gravity.Section(heading="one level of nesting, same shape")],
318
+ )],
319
+ sources=[gravity.Source(id="s1", title="Report", url="https://example.com")],
320
+ )
321
+ return {**corpus.to_state(), "result": {"title": corpus.title}}
322
+ ```
323
+
324
+ A plain dict of the same shape works too. The models catch a typo when the
325
+ corpus is built rather than when it's rendered. Set `result` as well: it's
326
+ what the run returns.
327
+
328
+ ## 9. Checklist
329
+
330
+ - [ ] `manifest.yaml` with `contract: v1`, every tool you call under `permissions.tools`
331
+ - [ ] `requirements.txt` exists (empty is fine)
332
+ - [ ] `src/agent.py` defines module-level `graph`: an `async def` factory (recommended) or a compiled graph
333
+ - [ ] env vars read inside `graph()`, not at module level
334
+ - [ ] state has `inputs`; final state sets `result`; `messages` only if the agent is conversational, with `add_messages`
335
+ - [ ] tools from `await gravity.tools()`; compiled with no checkpointer; nothing expensive before a gated call (§8)
336
+ - [ ] all imports under `src/` are absolute from `src/`
337
+ - [ ] no direct network calls — tools via the gateway, models via the LLM URL
338
+ - [ ] nothing your code needs lives outside `src/` or `tests/`
339
+ - [ ] `gravity build` passes; `gravity run` produces a `result`