gumloop 0.4.0__tar.gz → 0.4.2__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 (153) hide show
  1. {gumloop-0.4.0 → gumloop-0.4.2}/PKG-INFO +21 -1
  2. {gumloop-0.4.0 → gumloop-0.4.2}/README.md +20 -0
  3. {gumloop-0.4.0 → gumloop-0.4.2}/pyproject.toml +1 -1
  4. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/_client.py +9 -1
  5. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/_http.py +50 -2
  6. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/_version.py +1 -1
  7. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/resources/agents.py +10 -4
  8. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/resources/mcp.py +8 -0
  9. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/resources/skills.py +4 -0
  10. {gumloop-0.4.0 → gumloop-0.4.2}/tests/sdk/test_client.py +121 -0
  11. {gumloop-0.4.0 → gumloop-0.4.2}/uv.lock +1 -1
  12. {gumloop-0.4.0 → gumloop-0.4.2}/.env.example +0 -0
  13. {gumloop-0.4.0 → gumloop-0.4.2}/.gitignore +0 -0
  14. {gumloop-0.4.0 → gumloop-0.4.2}/.pre-commit-config.yaml +0 -0
  15. {gumloop-0.4.0 → gumloop-0.4.2}/.python-version +0 -0
  16. {gumloop-0.4.0 → gumloop-0.4.2}/LICENSE +0 -0
  17. {gumloop-0.4.0 → gumloop-0.4.2}/scripts/check +0 -0
  18. {gumloop-0.4.0 → gumloop-0.4.2}/scripts/fmt +0 -0
  19. {gumloop-0.4.0 → gumloop-0.4.2}/scripts/sdk_examples.py +0 -0
  20. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/__init__.py +0 -0
  21. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/_auth.py +0 -0
  22. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/cli/__init__.py +0 -0
  23. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/cli/commands/__init__.py +0 -0
  24. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/cli/commands/_args_input.py +0 -0
  25. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/cli/commands/_downloads.py +0 -0
  26. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/cli/commands/_sync_output.py +0 -0
  27. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/cli/commands/agents.py +0 -0
  28. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/cli/commands/artifacts.py +0 -0
  29. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/cli/commands/auth.py +0 -0
  30. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/cli/commands/brain.py +0 -0
  31. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/cli/commands/chat.py +0 -0
  32. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/cli/commands/mcp.py +0 -0
  33. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/cli/commands/sessions.py +0 -0
  34. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/cli/commands/skills.py +0 -0
  35. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/cli/commands/sync.py +0 -0
  36. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/cli/console.py +0 -0
  37. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/cli/context.py +0 -0
  38. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/cli/credentials.py +0 -0
  39. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/cli/errors.py +0 -0
  40. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/cli/main.py +0 -0
  41. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/cli/oauth.py +0 -0
  42. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/errors.py +0 -0
  43. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/oauth.py +0 -0
  44. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/py.typed +0 -0
  45. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/resources/__init__.py +0 -0
  46. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/resources/artifacts.py +0 -0
  47. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/resources/brain.py +0 -0
  48. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/resources/chat.py +0 -0
  49. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/resources/sessions.py +0 -0
  50. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/resources/sync.py +0 -0
  51. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/resources/teams.py +0 -0
  52. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/spec/__init__.py +0 -0
  53. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/spec/_compat.py +0 -0
  54. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/spec/_extensions.py +0 -0
  55. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/sync/__init__.py +0 -0
  56. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/sync/archive.py +0 -0
  57. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/sync/bundle.py +0 -0
  58. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/sync/bundle_validation.py +0 -0
  59. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/sync/errors.py +0 -0
  60. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/sync/hashing.py +0 -0
  61. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/sync/installed.py +0 -0
  62. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/sync/local_state.py +0 -0
  63. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/sync/lock.py +0 -0
  64. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/sync/markers.py +0 -0
  65. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/sync/reconcile.py +0 -0
  66. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/sync/reporting.py +0 -0
  67. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/sync/results.py +0 -0
  68. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/sync/run.py +0 -0
  69. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/sync/scheduler.py +0 -0
  70. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/sync/target_files.py +0 -0
  71. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/sync/targets.py +0 -0
  72. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/sync/wire.py +0 -0
  73. {gumloop-0.4.0 → gumloop-0.4.2}/src/gumloop/types.py +0 -0
  74. {gumloop-0.4.0 → gumloop-0.4.2}/tests/__init__.py +0 -0
  75. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/__init__.py +0 -0
  76. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/conftest.py +0 -0
  77. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/sync_scenario_helpers.py +0 -0
  78. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/sync_test_fakes.py +0 -0
  79. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_agents.py +0 -0
  80. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_args_input.py +0 -0
  81. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_artifacts.py +0 -0
  82. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_brain.py +0 -0
  83. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_chat.py +0 -0
  84. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_context.py +0 -0
  85. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_credentials.py +0 -0
  86. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_home_isolation.py +0 -0
  87. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_login.py +0 -0
  88. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_main.py +0 -0
  89. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_mcp.py +0 -0
  90. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_sessions.py +0 -0
  91. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_skills.py +0 -0
  92. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_sync_bundle.py +0 -0
  93. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_sync_command.py +0 -0
  94. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_sync_command_safety.py +0 -0
  95. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_sync_contract_fixtures.py +0 -0
  96. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_sync_enrollment.py +0 -0
  97. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_sync_fixture_corpus.py +0 -0
  98. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_sync_hashing.py +0 -0
  99. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_sync_installed.py +0 -0
  100. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_sync_reconciliation_safety.py +0 -0
  101. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_sync_scheduler_macos.py +0 -0
  102. {gumloop-0.4.0 → gumloop-0.4.2}/tests/cli/test_sync_targets.py +0 -0
  103. {gumloop-0.4.0 → gumloop-0.4.2}/tests/conftest.py +0 -0
  104. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/README.md +0 -0
  105. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/archives/generated-bundle-transfer-limit.json +0 -0
  106. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/archives/invalid-absolute-path.zip +0 -0
  107. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/archives/invalid-bytes-per-file-limit.zip +0 -0
  108. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/archives/invalid-device.zip +0 -0
  109. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/archives/invalid-duplicate-entry.zip +0 -0
  110. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/archives/invalid-fifo.zip +0 -0
  111. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/archives/invalid-files-per-skill-limit.zip +0 -0
  112. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/archives/invalid-nul-name.zip +0 -0
  113. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/archives/invalid-parent-traversal.zip +0 -0
  114. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/archives/invalid-reserved-marker.zip +0 -0
  115. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/archives/invalid-socket.zip +0 -0
  116. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/archives/invalid-symlink.zip +0 -0
  117. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/archives/invalid-total-uncompressed-limit.zip +0 -0
  118. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/archives/invalid-windows-drive-prefix.zip +0 -0
  119. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/archives/valid-single-skill-stored.zip +0 -0
  120. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/archives/valid-single-skill.zip +0 -0
  121. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/bundles/newer-than-plan.zip +0 -0
  122. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/bundles/normal.zip +0 -0
  123. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/catalog.json +0 -0
  124. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/corpus.sha256 +0 -0
  125. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/hashes/content-v1.json +0 -0
  126. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/hashes/manifest-v1.json +0 -0
  127. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/manifests/invalid-duplicate-identity.json +0 -0
  128. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/manifests/invalid-duplicate-install-name.json +0 -0
  129. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/manifests/invalid-manifest-hash.json +0 -0
  130. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/manifests/newer-than-plan.json +0 -0
  131. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/manifests/normal-bundle.json +0 -0
  132. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/responses/below-pro.json +0 -0
  133. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/responses/cli-upgrade-required.json +0 -0
  134. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/responses/lost-membership.json +0 -0
  135. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/responses/normal-empty-plan.json +0 -0
  136. {gumloop-0.4.0 → gumloop-0.4.2}/tests/fixtures/skill_sync/v1/responses/normal-plan.json +0 -0
  137. {gumloop-0.4.0 → gumloop-0.4.2}/tests/integration/conftest.py +0 -0
  138. {gumloop-0.4.0 → gumloop-0.4.2}/tests/integration/test_live.py +0 -0
  139. {gumloop-0.4.0 → gumloop-0.4.2}/tests/sdk/__init__.py +0 -0
  140. {gumloop-0.4.0 → gumloop-0.4.2}/tests/sdk/conftest.py +0 -0
  141. {gumloop-0.4.0 → gumloop-0.4.2}/tests/sdk/helpers.py +0 -0
  142. {gumloop-0.4.0 → gumloop-0.4.2}/tests/sdk/test_agents.py +0 -0
  143. {gumloop-0.4.0 → gumloop-0.4.2}/tests/sdk/test_artifacts.py +0 -0
  144. {gumloop-0.4.0 → gumloop-0.4.2}/tests/sdk/test_brain.py +0 -0
  145. {gumloop-0.4.0 → gumloop-0.4.2}/tests/sdk/test_chat.py +0 -0
  146. {gumloop-0.4.0 → gumloop-0.4.2}/tests/sdk/test_mcp.py +0 -0
  147. {gumloop-0.4.0 → gumloop-0.4.2}/tests/sdk/test_oauth.py +0 -0
  148. {gumloop-0.4.0 → gumloop-0.4.2}/tests/sdk/test_sessions.py +0 -0
  149. {gumloop-0.4.0 → gumloop-0.4.2}/tests/sdk/test_skills.py +0 -0
  150. {gumloop-0.4.0 → gumloop-0.4.2}/tests/sdk/test_sync.py +0 -0
  151. {gumloop-0.4.0 → gumloop-0.4.2}/tests/sdk/test_teams.py +0 -0
  152. {gumloop-0.4.0 → gumloop-0.4.2}/tests/skill_sync_fixtures.py +0 -0
  153. {gumloop-0.4.0 → gumloop-0.4.2}/tests/test_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gumloop
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Python client for the Gumloop API
5
5
  Project-URL: Homepage, https://github.com/gumloop/gumloop-py
6
6
  Project-URL: Bug Tracker, https://github.com/gumloop/gumloop-py/issues
@@ -57,6 +57,26 @@ output = client.run_flow(
57
57
  print(output)
58
58
  ```
59
59
 
60
+ ## Authenticate with a team API key
61
+
62
+ Team (workspace) API keys are scoped to a single team. Pass the team's ID and
63
+ the acting member's user ID — every request is validated against that team and
64
+ only reaches resources the team owns.
65
+
66
+ ```python
67
+ from gumloop import Gumloop
68
+
69
+ client = Gumloop(
70
+ api_key="your_team_api_key",
71
+ user_id="your_user_id", # must be a member of the team
72
+ team_id="your_team_id",
73
+ )
74
+
75
+ agents = client.agents.list() # scoped to the team
76
+ ```
77
+
78
+ `team_id` can also be provided via the `GUMLOOP_TEAM_ID` environment variable.
79
+
60
80
  ## Chat with an agent (streaming)
61
81
 
62
82
  ```python
@@ -32,6 +32,26 @@ output = client.run_flow(
32
32
  print(output)
33
33
  ```
34
34
 
35
+ ## Authenticate with a team API key
36
+
37
+ Team (workspace) API keys are scoped to a single team. Pass the team's ID and
38
+ the acting member's user ID — every request is validated against that team and
39
+ only reaches resources the team owns.
40
+
41
+ ```python
42
+ from gumloop import Gumloop
43
+
44
+ client = Gumloop(
45
+ api_key="your_team_api_key",
46
+ user_id="your_user_id", # must be a member of the team
47
+ team_id="your_team_id",
48
+ )
49
+
50
+ agents = client.agents.list() # scoped to the team
51
+ ```
52
+
53
+ `team_id` can also be provided via the `GUMLOOP_TEAM_ID` environment variable.
54
+
35
55
  ## Chat with an agent (streaming)
36
56
 
37
57
  ```python
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "gumloop"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "Python client for the Gumloop API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -32,7 +32,7 @@ from gumloop.resources import Sync
32
32
  from gumloop.resources import Teams
33
33
 
34
34
  DEFAULT_BASE_URL = "https://api.gumloop.com/api/v1"
35
- DEFAULT_TIMEOUT = 30.0
35
+ DEFAULT_TIMEOUT = 90.0
36
36
  DEFAULT_STREAM_TIMEOUT = 3600.0
37
37
 
38
38
 
@@ -55,6 +55,7 @@ class Gumloop:
55
55
  *,
56
56
  access_token: str | None = None,
57
57
  user_id: str | None = None,
58
+ team_id: str | None = None,
58
59
  base_url: str | None = None,
59
60
  stream_base_url: str | None = None,
60
61
  timeout: float = DEFAULT_TIMEOUT,
@@ -66,6 +67,8 @@ class Gumloop:
66
67
  # x-auth-key header to look up the owner's stored secret. OAuth
67
68
  # callers omit it because the bearer token already identifies them.
68
69
  self.user_id = user_id or os.environ.get("GUMLOOP_USER_ID")
70
+ # Team API keys are validated against ``team_id``; ``user_id`` stays the acting member.
71
+ self.team_id = team_id or os.environ.get("GUMLOOP_TEAM_ID")
69
72
  # Defaults to production; GUMLOOP_BASE_URL is an optional override.
70
73
  self.base_url = (base_url or os.environ.get("GUMLOOP_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
71
74
  self.stream_base_url = (stream_base_url or _derive_stream_base_url(self.base_url)).rstrip("/")
@@ -77,6 +80,7 @@ class Gumloop:
77
80
  stream_base_url=self.stream_base_url,
78
81
  access_token=self.access_token,
79
82
  user_id=self.user_id,
83
+ team_id=self.team_id,
80
84
  timeout=self.timeout,
81
85
  stream_timeout=self.stream_timeout,
82
86
  )
@@ -112,6 +116,7 @@ class AsyncGumloop:
112
116
  *,
113
117
  access_token: str | None = None,
114
118
  user_id: str | None = None,
119
+ team_id: str | None = None,
115
120
  base_url: str | None = None,
116
121
  stream_base_url: str | None = None,
117
122
  timeout: float = DEFAULT_TIMEOUT,
@@ -120,6 +125,8 @@ class AsyncGumloop:
120
125
  self.api_key = api_key
121
126
  self.access_token = access_token or api_key or EnvToken.from_env()
122
127
  self.user_id = user_id or os.environ.get("GUMLOOP_USER_ID")
128
+ # Team API keys are validated against ``team_id``; ``user_id`` stays the acting member.
129
+ self.team_id = team_id or os.environ.get("GUMLOOP_TEAM_ID")
123
130
  # Defaults to production; GUMLOOP_BASE_URL is an optional override.
124
131
  self.base_url = (base_url or os.environ.get("GUMLOOP_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
125
132
  self.stream_base_url = (stream_base_url or _derive_stream_base_url(self.base_url)).rstrip("/")
@@ -131,6 +138,7 @@ class AsyncGumloop:
131
138
  stream_base_url=self.stream_base_url,
132
139
  access_token=self.access_token,
133
140
  user_id=self.user_id,
141
+ team_id=self.team_id,
134
142
  timeout=self.timeout,
135
143
  stream_timeout=self.stream_timeout,
136
144
  )
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
+ import os
4
5
  from collections.abc import AsyncIterator
5
6
  from collections.abc import Iterator
6
7
  from collections.abc import Mapping
@@ -19,6 +20,29 @@ from gumloop.types import StreamEvent
19
20
 
20
21
  logger = logging.getLogger(__name__)
21
22
 
23
+
24
+ def _inject_trace_context(request: httpx.Request) -> None:
25
+ """Request event hook: attach W3C trace context when the host sets TRACEPARENT.
26
+
27
+ Read per request: sandbox runtimes rotate env between calls. setdefault so
28
+ an explicitly provided header wins.
29
+ """
30
+ traceparent = os.environ.get("TRACEPARENT")
31
+ if not traceparent:
32
+ return
33
+ request.headers.setdefault("traceparent", traceparent)
34
+ tracestate = os.environ.get("TRACESTATE")
35
+ if tracestate:
36
+ request.headers.setdefault("tracestate", tracestate)
37
+
38
+
39
+ async def _inject_trace_context_async(request: httpx.Request) -> None:
40
+ _inject_trace_context(request)
41
+
42
+
43
+ _TRACE_EVENT_HOOKS = {"request": [_inject_trace_context]}
44
+ _ASYNC_TRACE_EVENT_HOOKS = {"request": [_inject_trace_context_async]}
45
+
22
46
  _DONE_SENTINEL = "[DONE]"
23
47
  _T = TypeVar("_T", bound=BaseModel)
24
48
 
@@ -64,14 +88,24 @@ class HttpClient:
64
88
  stream_base_url: str,
65
89
  access_token: str | None,
66
90
  user_id: str | None,
91
+ team_id: str | None = None,
67
92
  timeout: float,
68
93
  stream_timeout: float | None,
69
94
  ) -> None:
70
95
  self.access_token = access_token
71
96
  self.user_id = user_id
97
+ self.team_id = team_id
72
98
  self._stream_base_url = stream_base_url.rstrip("/")
73
99
  self._stream_timeout = stream_timeout
74
- self._client = httpx.Client(base_url=base_url.rstrip("/"), timeout=timeout)
100
+ self._client = httpx.Client(base_url=base_url.rstrip("/"), timeout=timeout, event_hooks=_TRACE_EVENT_HOOKS)
101
+
102
+ def _scoped_params(self, params: dict[str, Any] | None) -> dict[str, Any] | None:
103
+ # Team keys are validated against ``team_id``, so it rides on every request.
104
+ if not self.team_id:
105
+ return params
106
+ merged = dict(params or {})
107
+ merged.setdefault("team_id", self.team_id)
108
+ return merged
75
109
 
76
110
  def close(self) -> None:
77
111
  self._client.close()
@@ -234,6 +268,7 @@ class HttpClient:
234
268
  headers["Content-Type"] = "application/json"
235
269
  if extra_headers:
236
270
  headers.update(extra_headers)
271
+ kwargs["params"] = self._scoped_params(kwargs.get("params"))
237
272
  response = self._client.request(method, path, headers=headers, **kwargs)
238
273
  if response.status_code >= 400:
239
274
  raise to_api_error(response)
@@ -250,14 +285,26 @@ class AsyncHttpClient:
250
285
  stream_base_url: str,
251
286
  access_token: str | None,
252
287
  user_id: str | None,
288
+ team_id: str | None = None,
253
289
  timeout: float,
254
290
  stream_timeout: float | None,
255
291
  ) -> None:
256
292
  self.access_token = access_token
257
293
  self.user_id = user_id
294
+ self.team_id = team_id
258
295
  self._stream_base_url = stream_base_url.rstrip("/")
259
296
  self._stream_timeout = stream_timeout
260
- self._client = httpx.AsyncClient(base_url=base_url.rstrip("/"), timeout=timeout)
297
+ self._client = httpx.AsyncClient(
298
+ base_url=base_url.rstrip("/"), timeout=timeout, event_hooks=_ASYNC_TRACE_EVENT_HOOKS
299
+ )
300
+
301
+ def _scoped_params(self, params: dict[str, Any] | None) -> dict[str, Any] | None:
302
+ # Team keys are validated against ``team_id``, so it rides on every request.
303
+ if not self.team_id:
304
+ return params
305
+ merged = dict(params or {})
306
+ merged.setdefault("team_id", self.team_id)
307
+ return merged
261
308
 
262
309
  async def aclose(self) -> None:
263
310
  await self._client.aclose()
@@ -371,6 +418,7 @@ class AsyncHttpClient:
371
418
  headers = auth_headers(self.access_token, self.user_id)
372
419
  if not kwargs.get("files"):
373
420
  headers["Content-Type"] = "application/json"
421
+ kwargs["params"] = self._scoped_params(kwargs.get("params"))
374
422
  response = await self._client.request(method, path, headers=headers, **kwargs)
375
423
  if response.status_code >= 400:
376
424
  raise to_api_error(response)
@@ -1,3 +1,3 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.4.2"
@@ -46,9 +46,11 @@ class Agents:
46
46
  request: AgentCreateRequest | Mapping[str, Any] | None = None,
47
47
  **kwargs: Any,
48
48
  ) -> AgentResponse:
49
- return AgentResponse.model_validate(
50
- self._client.post("agents", json=AgentCreateRequest.build(request, **kwargs))
51
- )
49
+ body = AgentCreateRequest.build(request, **kwargs)
50
+ # The create workspace is read from the body, not the query.
51
+ if self._client.team_id is not None:
52
+ body.setdefault("team_id", self._client.team_id)
53
+ return AgentResponse.model_validate(self._client.post("agents", json=body))
52
54
 
53
55
  def retrieve(self, agent_id: str) -> AgentResponse:
54
56
  return AgentResponse.model_validate(self._client.get(f"agents/{agent_id}"))
@@ -169,7 +171,11 @@ class AsyncAgents:
169
171
  request: AgentCreateRequest | Mapping[str, Any] | None = None,
170
172
  **kwargs: Any,
171
173
  ) -> AgentResponse:
172
- data = await self._client.post("agents", json=AgentCreateRequest.build(request, **kwargs))
174
+ body = AgentCreateRequest.build(request, **kwargs)
175
+ # The create workspace is read from the body, not the query.
176
+ if self._client.team_id is not None:
177
+ body.setdefault("team_id", self._client.team_id)
178
+ data = await self._client.post("agents", json=body)
173
179
  return AgentResponse.model_validate(data)
174
180
 
175
181
  async def retrieve(self, agent_id: str) -> AgentResponse:
@@ -79,6 +79,8 @@ class MCP:
79
79
  *,
80
80
  team_id: str | None = None,
81
81
  ) -> McpPromptResponse:
82
+ # team_id is read from the body here; the query-level default doesn't reach it.
83
+ team_id = team_id if team_id is not None else self._client.team_id
82
84
  body: dict[str, Any] = {"name": name, "arguments": arguments or {}}
83
85
  if team_id is not None:
84
86
  body["team_id"] = team_id
@@ -93,6 +95,7 @@ class MCP:
93
95
  ref: str | None = None,
94
96
  team_id: str | None = None,
95
97
  ) -> McpExecuteResponse:
98
+ team_id = team_id if team_id is not None else self._client.team_id
96
99
  call = McpToolCallRequest(
97
100
  server_id=server_id,
98
101
  tool_name=tool_name,
@@ -107,6 +110,7 @@ class MCP:
107
110
  *,
108
111
  team_id: str | None = None,
109
112
  ) -> McpExecuteResponse:
113
+ team_id = team_id if team_id is not None else self._client.team_id
110
114
  return McpExecuteResponse.model_validate(self._client.post("mcp/tools/call", json=_call_body(calls, team_id)))
111
115
 
112
116
 
@@ -156,6 +160,8 @@ class AsyncMCP:
156
160
  *,
157
161
  team_id: str | None = None,
158
162
  ) -> McpPromptResponse:
163
+ # team_id is read from the body here; the query-level default doesn't reach it.
164
+ team_id = team_id if team_id is not None else self._client.team_id
159
165
  body: dict[str, Any] = {"name": name, "arguments": arguments or {}}
160
166
  if team_id is not None:
161
167
  body["team_id"] = team_id
@@ -171,6 +177,7 @@ class AsyncMCP:
171
177
  ref: str | None = None,
172
178
  team_id: str | None = None,
173
179
  ) -> McpExecuteResponse:
180
+ team_id = team_id if team_id is not None else self._client.team_id
174
181
  call = McpToolCallRequest(
175
182
  server_id=server_id,
176
183
  tool_name=tool_name,
@@ -186,5 +193,6 @@ class AsyncMCP:
186
193
  *,
187
194
  team_id: str | None = None,
188
195
  ) -> McpExecuteResponse:
196
+ team_id = team_id if team_id is not None else self._client.team_id
189
197
  data = await self._client.post("mcp/tools/call", json=_call_body(calls, team_id))
190
198
  return McpExecuteResponse.model_validate(data)
@@ -70,6 +70,8 @@ class Skills:
70
70
  team_id: str | None = None,
71
71
  **kwargs: Any,
72
72
  ) -> SkillResponse:
73
+ # The create workspace is read from the form body, not the query.
74
+ team_id = team_id if team_id is not None else self._client.team_id
73
75
  return SkillResponse.model_validate(
74
76
  self._client.post(
75
77
  "skills",
@@ -134,6 +136,8 @@ class AsyncSkills:
134
136
  team_id: str | None = None,
135
137
  **kwargs: Any,
136
138
  ) -> SkillResponse:
139
+ # The create workspace is read from the form body, not the query.
140
+ team_id = team_id if team_id is not None else self._client.team_id
137
141
  data = await self._client.post(
138
142
  "skills",
139
143
  data={"team_id": team_id, **kwargs},
@@ -13,6 +13,7 @@ from gumloop.errors import APIStatusError
13
13
  from gumloop.errors import AuthenticationError
14
14
  from tests.sdk.helpers import API_BASE
15
15
  from tests.sdk.helpers import auth_header
16
+ from tests.sdk.helpers import request_json
16
17
 
17
18
 
18
19
  @respx.mock
@@ -202,3 +203,123 @@ def test_async_user_id_sets_x_auth_key_header() -> None:
202
203
  asyncio.run(run())
203
204
 
204
205
  assert route.calls[0].request.headers["x-auth-key"] == "user_123"
206
+
207
+
208
+ _AGENT_JSON = {"agent": {"id": "agent_1", "name": "Agent"}}
209
+
210
+
211
+ @respx.mock
212
+ def test_team_id_is_sent_as_query_param_on_every_request() -> None:
213
+ # Team keys are validated against ``team_id`` — it must be on every request.
214
+ route = respx.get(f"{API_BASE}/agents/agent_1").mock(return_value=httpx.Response(200, json=_AGENT_JSON))
215
+
216
+ Gumloop(api_key="team_key", user_id="user_123", team_id="team_1").agents.retrieve("agent_1")
217
+
218
+ assert route.calls[0].request.url.params["team_id"] == "team_1"
219
+
220
+
221
+ @respx.mock
222
+ def test_explicit_team_id_wins_over_client_team_id() -> None:
223
+ route = respx.get(f"{API_BASE}/agents").mock(return_value=httpx.Response(200, json={"agents": []}))
224
+
225
+ Gumloop(api_key="team_key", user_id="user_123", team_id="team_1").agents.list(team_id="team_2")
226
+
227
+ assert route.calls[0].request.url.params["team_id"] == "team_2"
228
+
229
+
230
+ @respx.mock
231
+ def test_no_team_id_omits_query_param() -> None:
232
+ route = respx.get(f"{API_BASE}/models").mock(return_value=httpx.Response(200, json={"model_groups": []}))
233
+
234
+ Gumloop(api_key="gum_xxx", user_id="user_123").models.list()
235
+
236
+ assert "team_id" not in route.calls[0].request.url.params
237
+
238
+
239
+ @respx.mock
240
+ def test_env_team_id_is_used_when_not_passed_explicitly(monkeypatch: pytest.MonkeyPatch) -> None:
241
+ monkeypatch.setenv("GUMLOOP_TEAM_ID", "env_team")
242
+ route = respx.get(f"{API_BASE}/models").mock(return_value=httpx.Response(200, json={"model_groups": []}))
243
+
244
+ Gumloop(api_key="team_key", user_id="user_123").models.list()
245
+
246
+ assert route.calls[0].request.url.params["team_id"] == "env_team"
247
+
248
+
249
+ @respx.mock
250
+ def test_team_id_defaults_into_mcp_execute_body() -> None:
251
+ # /mcp/tools/call reads team_id from the body — a query-only default would
252
+ # authenticate as the team but silently execute with personal credentials.
253
+ route = respx.post(f"{API_BASE}/mcp/tools/call").mock(return_value=httpx.Response(200, json={"results": []}))
254
+
255
+ Gumloop(api_key="team_key", user_id="user_123", team_id="team_1").mcp.execute("server_1", "tool_1")
256
+
257
+ assert request_json(route.calls[0].request)["team_id"] == "team_1"
258
+
259
+
260
+ @respx.mock
261
+ def test_team_id_defaults_into_agent_create_body() -> None:
262
+ route = respx.post(f"{API_BASE}/agents").mock(return_value=httpx.Response(201, json=_AGENT_JSON))
263
+
264
+ client = Gumloop(api_key="team_key", user_id="user_123", team_id="team_1")
265
+ client.agents.create(name="Agent", model_name="model")
266
+ client.agents.create(name="Agent", model_name="model", team_id="team_2")
267
+
268
+ assert request_json(route.calls[0].request)["team_id"] == "team_1"
269
+ assert request_json(route.calls[1].request)["team_id"] == "team_2"
270
+
271
+
272
+ @respx.mock
273
+ def test_async_team_id_is_sent_as_query_param() -> None:
274
+ route = respx.get(f"{API_BASE}/agents/agent_1").mock(return_value=httpx.Response(200, json=_AGENT_JSON))
275
+
276
+ async def run() -> None:
277
+ async with AsyncGumloop(api_key="team_key", user_id="user_123", team_id="team_1") as client:
278
+ await client.agents.retrieve("agent_1")
279
+
280
+ asyncio.run(run())
281
+
282
+ assert route.calls[0].request.url.params["team_id"] == "team_1"
283
+
284
+
285
+ @respx.mock
286
+ def test_traceparent_env_is_forwarded_as_header(monkeypatch: pytest.MonkeyPatch) -> None:
287
+ monkeypatch.setenv("GUMLOOP_ACCESS_TOKEN", "env-token")
288
+ monkeypatch.setenv("TRACEPARENT", "00-11111111111111111111111111111111-2222222222222222-01")
289
+ monkeypatch.setenv("TRACESTATE", "gumloop=1")
290
+ route = respx.get(f"{API_BASE}/models").mock(return_value=httpx.Response(200, json={"model_groups": []}))
291
+
292
+ Gumloop().models.list()
293
+
294
+ request = route.calls[0].request
295
+ assert request.headers["traceparent"] == "00-11111111111111111111111111111111-2222222222222222-01"
296
+ assert request.headers["tracestate"] == "gumloop=1"
297
+
298
+
299
+ @respx.mock
300
+ def test_no_trace_headers_without_traceparent_env(monkeypatch: pytest.MonkeyPatch) -> None:
301
+ monkeypatch.setenv("GUMLOOP_ACCESS_TOKEN", "env-token")
302
+ monkeypatch.delenv("TRACEPARENT", raising=False)
303
+ route = respx.get(f"{API_BASE}/models").mock(return_value=httpx.Response(200, json={"model_groups": []}))
304
+
305
+ Gumloop().models.list()
306
+
307
+ request = route.calls[0].request
308
+ assert "traceparent" not in request.headers
309
+ assert "tracestate" not in request.headers
310
+
311
+
312
+ @respx.mock
313
+ def test_traceparent_env_is_forwarded_on_async_client(monkeypatch: pytest.MonkeyPatch) -> None:
314
+ monkeypatch.setenv("GUMLOOP_ACCESS_TOKEN", "env-token")
315
+ monkeypatch.setenv("TRACEPARENT", "00-11111111111111111111111111111111-2222222222222222-01")
316
+ route = respx.get(f"{API_BASE}/models").mock(return_value=httpx.Response(200, json={"model_groups": []}))
317
+
318
+ async def run() -> None:
319
+ async with AsyncGumloop() as client:
320
+ await client.models.list()
321
+
322
+ asyncio.run(run())
323
+
324
+ request = route.calls[0].request
325
+ assert request.headers["traceparent"] == "00-11111111111111111111111111111111-2222222222222222-01"
@@ -355,7 +355,7 @@ wheels = [
355
355
 
356
356
  [[package]]
357
357
  name = "gumloop"
358
- version = "0.4.0"
358
+ version = "0.4.2"
359
359
  source = { editable = "." }
360
360
  dependencies = [
361
361
  { name = "click" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes