API: pinned projects over REST and MCP
Four operations, a person's own like the inbox: list_pinned_projects, pin_project (at a position, or the end), unpin_project and reorder_pinned_projects. REST at GET and PUT /user/pinned_projects/ {workspace} and PUT and DELETE /user/pinned_projects/{workspace}/{project}; MCP as four actions of the workspace tool. account:read and account:write gate them; workspace and agent tokens are refused, g1t's agents never use them, and they are not audited. Responses are snake_case pins with their position and address. The API reaches the projects service through a new PROJECTS service binding. OpenAPI copy and reference examples updated.
13 files+472−120/13 viewed
| 12 | 12 | mod notifications; | |
| 13 | 13 | mod oauth; | |
| 14 | 14 | mod openapi; | |
| 15 | + | mod pins; | |
| 15 | 16 | mod operations; | |
| 16 | 17 | mod renamed; | |
| 17 | 18 | #[cfg(test)] |
| 39 | 39 | ], | |
| 40 | 40 | ), | |
| 41 | 41 | ( | |
| 42 | + | "Pinned projects", | |
| 43 | + | "The projects you keep at the top of a workspace's sidebar, in your order, up to eight a workspace. Your own: personal tokens and sessions only.", | |
| 44 | + | &[Op::ListPinnedProjects, Op::PinProject, Op::UnpinProject, Op::ReorderPinnedProjects], | |
| 45 | + | ), | |
| 46 | + | ( | |
| 42 | 47 | "Workspaces", | |
| 43 | 48 | "A workspace owns repositories and is the first part of their address. People and agents work in workspaces.", | |
| 44 | 49 | &[Op::CreateWorkspace, Op::UpdateWorkspace, Op::DeleteWorkspace], | |
| 373 | 378 | Op::SetRepoSubscription => "Watch a repository", | |
| 374 | 379 | Op::DeleteRepoSubscription => "Stop watching a repository", | |
| 375 | 380 | Op::ListWatchedRepos => "List repositories you watch", | |
| 381 | + | Op::ListPinnedProjects => "List your pinned projects", | |
| 382 | + | Op::PinProject => "Pin a project", | |
| 383 | + | Op::UnpinProject => "Unpin a project", | |
| 384 | + | Op::ReorderPinnedProjects => "Reorder your pinned projects", | |
| 376 | 385 | } | |
| 377 | 386 | } | |
| 378 | 387 |
| 44 | 44 | pub search: Fetcher, | |
| 45 | 45 | /// Secret and dependency alerts. | |
| 46 | 46 | pub security: Fetcher, | |
| 47 | + | /// Projects: a person's pinned ones. | |
| 48 | + | pub projects: Fetcher, | |
| 47 | 49 | /// Where the request came in, for its audit entries. | |
| 48 | 50 | pub audit: crate::audit::AuditContext, | |
| 49 | 51 | /// Set for a request made with an agent's token: all it may do. | |
| 67 | 69 | context: env.service("CONTEXT")?, | |
| 68 | 70 | search: env.service("SEARCH")?, | |
| 69 | 71 | security: env.service("SECURITY")?, | |
| 72 | + | projects: env.service("PROJECTS")?, | |
| 70 | 73 | scope: None, | |
| 71 | 74 | audit: crate::audit::AuditContext::default(), | |
| 72 | 75 | addresses: crate::addresses::Addresses::from_env(env), | |
| 207 | 210 | SetRepoSubscription, | |
| 208 | 211 | DeleteRepoSubscription, | |
| 209 | 212 | ListWatchedRepos, | |
| 213 | + | ListPinnedProjects, | |
| 214 | + | PinProject, | |
| 215 | + | UnpinProject, | |
| 216 | + | ReorderPinnedProjects, | |
| 210 | 217 | } | |
| 211 | 218 | ||
| 212 | 219 | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| 454 | 461 | } | |
| 455 | 462 | ||
| 456 | 463 | impl Op { | |
| 457 | − | pub const ALL: [Op; 131] = [ | |
| 464 | + | pub const ALL: [Op; 135] = [ | |
| 458 | 465 | Op::Whoami, | |
| 459 | 466 | Op::CreateWorkspace, | |
| 460 | 467 | Op::DeleteWorkspace, | |
| 586 | 593 | Op::SetRepoSubscription, | |
| 587 | 594 | Op::DeleteRepoSubscription, | |
| 588 | 595 | Op::ListWatchedRepos, | |
| 596 | + | Op::ListPinnedProjects, | |
| 597 | + | Op::PinProject, | |
| 598 | + | Op::UnpinProject, | |
| 599 | + | Op::ReorderPinnedProjects, | |
| 589 | 600 | ]; | |
| 590 | 601 | ||
| 591 | 602 | pub fn by_name(name: &str) -> Option<Op> { | |
| 726 | 737 | Op::SetRepoSubscription => "set_repo_subscription", | |
| 727 | 738 | Op::DeleteRepoSubscription => "delete_repo_subscription", | |
| 728 | 739 | Op::ListWatchedRepos => "list_watched_repos", | |
| 740 | + | Op::ListPinnedProjects => "list_pinned_projects", | |
| 741 | + | Op::PinProject => "pin_project", | |
| 742 | + | Op::UnpinProject => "unpin_project", | |
| 743 | + | Op::ReorderPinnedProjects => "reorder_pinned_projects", | |
| 729 | 744 | } | |
| 730 | 745 | } | |
| 731 | 746 | ||
| 1090 | 1105 | Op::ListWatchedRepos => { | |
| 1091 | 1106 | "The repositories you watch other than the default way: all activity, custom or ignored, each with its `level` and `events`." | |
| 1092 | 1107 | } | |
| 1108 | + | Op::ListPinnedProjects => { | |
| 1109 | + | "Your pinned projects in a workspace, in your order (`position` 0 first): the ones its sidebar keeps at the top for you. Projects you can no longer see are left out. Your own: a personal access token or a session." | |
| 1110 | + | } | |
| 1111 | + | Op::PinProject => { | |
| 1112 | + | "Pin a project you can see, at `position` (0 first) or at the end; pinning one already pinned moves it. At most 8 a workspace: unpin one first when you have 8. Returns your pins, in order." | |
| 1113 | + | } | |
| 1114 | + | Op::UnpinProject => { | |
| 1115 | + | "Unpin a project. Unpinning one that is not pinned changes nothing. Returns your pins, in order." | |
| 1116 | + | } | |
| 1117 | + | Op::ReorderPinnedProjects => { | |
| 1118 | + | "Put your pins in a workspace in a new order: `projects` names every pinned project's slug, once, in the order you want them. Returns your pins, in order." | |
| 1119 | + | } | |
| 1093 | 1120 | } | |
| 1094 | 1121 | } | |
| 1095 | 1122 | ||
| 2080 | 2107 | &["repo"], | |
| 2081 | 2108 | ), | |
| 2082 | 2109 | Op::ListWatchedRepos => object(json!({}), &[]), | |
| 2110 | + | Op::ListPinnedProjects => object(json!({ "workspace": workspace_schema() }), &["workspace"]), | |
| 2111 | + | Op::PinProject => object( | |
| 2112 | + | json!({ | |
| 2113 | + | "workspace": workspace_schema(), | |
| 2114 | + | "project": { "type": "string", "description": "The project's slug, as in g1t.sh/{workspace}/{project}." }, | |
| 2115 | + | "position": { "type": "integer", "description": "Where it goes, 0 first. Left out: at the end." }, | |
| 2116 | + | }), | |
| 2117 | + | &["workspace", "project"], | |
| 2118 | + | ), | |
| 2119 | + | Op::UnpinProject => object( | |
| 2120 | + | json!({ | |
| 2121 | + | "workspace": workspace_schema(), | |
| 2122 | + | "project": { "type": "string", "description": "The project's slug, as in g1t.sh/{workspace}/{project}." }, | |
| 2123 | + | }), | |
| 2124 | + | &["workspace", "project"], | |
| 2125 | + | ), | |
| 2126 | + | Op::ReorderPinnedProjects => object( | |
| 2127 | + | json!({ | |
| 2128 | + | "workspace": workspace_schema(), | |
| 2129 | + | "projects": { | |
| 2130 | + | "type": "array", | |
| 2131 | + | "items": { "type": "string" }, | |
| 2132 | + | "description": "Every pinned project's slug, once, in the order you want them.", | |
| 2133 | + | }, | |
| 2134 | + | }), | |
| 2135 | + | &["workspace", "projects"], | |
| 2136 | + | ), | |
| 2083 | 2137 | } | |
| 2084 | 2138 | } | |
| 2085 | 2139 | ||
| 2177 | 2231 | | Op::SetThreadSubscription | |
| 2178 | 2232 | | Op::DeleteThreadSubscription | |
| 2179 | 2233 | | Op::ListWatchedRepos | |
| 2234 | + | | Op::ListPinnedProjects | |
| 2235 | + | | Op::PinProject | |
| 2236 | + | | Op::UnpinProject | |
| 2237 | + | | Op::ReorderPinnedProjects | |
| 2180 | 2238 | ) | |
| 2181 | 2239 | } | |
| 2182 | 2240 | ||
| 2183 | − | /// Whether the operation is about the caller's own inbox: notifications, | |
| 2184 | − | /// subscriptions and watching. Nobody else's business, so not audited. | |
| 2241 | + | /// Whether the operation is about the caller's own inbox (notifications, | |
| 2242 | + | /// subscriptions and watching) or their pins. Nobody else's business, | |
| 2243 | + | /// so not audited. | |
| 2185 | 2244 | pub(crate) fn personal(self) -> bool { | |
| 2186 | 2245 | matches!( | |
| 2187 | 2246 | self, | |
| 2199 | 2258 | | Op::SetRepoSubscription | |
| 2200 | 2259 | | Op::DeleteRepoSubscription | |
| 2201 | 2260 | | Op::ListWatchedRepos | |
| 2261 | + | | Op::ListPinnedProjects | |
| 2262 | + | | Op::PinProject | |
| 2263 | + | | Op::UnpinProject | |
| 2264 | + | | Op::ReorderPinnedProjects | |
| 2202 | 2265 | ) | |
| 2203 | 2266 | } | |
| 2204 | 2267 | ||
| 3617 | 3680 | | Op::SetRepoSubscription | |
| 3618 | 3681 | | Op::DeleteRepoSubscription | |
| 3619 | 3682 | | Op::ListWatchedRepos => crate::notifications::run(self, services, viewer, input).await, | |
| 3683 | + | // A person's pinned projects: the projects service keeps them. | |
| 3684 | + | Op::ListPinnedProjects | Op::PinProject | Op::UnpinProject | Op::ReorderPinnedProjects => { | |
| 3685 | + | crate::pins::run(self, services, viewer, input).await | |
| 3686 | + | } | |
| 3620 | 3687 | Op::ReopenSecurityAlert => { | |
| 3621 | 3688 | let changed: Outcome<AlertChange> = call( | |
| 3622 | 3689 | &services.security, |
| 1 | + | //! Pinned projects: the projects a person keeps at the top of a | |
| 2 | + | //! workspace's sidebar, in their order, up to eight a workspace. The | |
| 3 | + | //! projects service keeps them; this is their public shape, in snake_case. | |
| 4 | + | //! | |
| 5 | + | //! A person's own: a personal access token or a session, never a | |
| 6 | + | //! workspace's token or g1t's agents. | |
| 7 | + | ||
| 8 | + | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Viewer}; | |
| 9 | + | use serde_json::{Value, json}; | |
| 10 | + | use worker::Result; | |
| 11 | + | ||
| 12 | + | use crate::operations::{Op, Services}; | |
| 13 | + | ||
| 14 | + | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| 15 | + | Ok(Outcome::fail(code, message)) | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | fn text(input: &Value, key: &str) -> Option<String> { | |
| 19 | + | input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_lowercase) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | /// A place in the pins, 0 first, given as a number or as digits. | |
| 23 | + | fn position(input: &Value) -> Option<u32> { | |
| 24 | + | match &input["position"] { | |
| 25 | + | Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()), | |
| 26 | + | Value::String(digits) => digits.trim().parse().ok(), | |
| 27 | + | _ => None, | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /// The slugs `projects` names, in order: strings, or objects with a `slug`. | |
| 32 | + | pub(crate) fn slugs(input: &Value) -> Option<Vec<String>> { | |
| 33 | + | input["projects"].as_array().map(|items| { | |
| 34 | + | items | |
| 35 | + | .iter() | |
| 36 | + | .filter_map(|item| item.as_str().or_else(|| item["slug"].as_str())) | |
| 37 | + | .map(|slug| slug.trim().to_lowercase()) | |
| 38 | + | .filter(|slug| !slug.is_empty()) | |
| 39 | + | .collect() | |
| 40 | + | }) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /// A project as the projects service answers (camelCase), as the API shows | |
| 44 | + | /// a pin: its place, what it is, and where it is. | |
| 45 | + | pub(crate) fn pin_json(project: &Value, position: usize, site: &str) -> Value { | |
| 46 | + | let workspace = project["workspace"].as_str().unwrap_or_default(); | |
| 47 | + | let slug = project["slug"].as_str().unwrap_or_default(); | |
| 48 | + | json!({ | |
| 49 | + | "position": position, | |
| 50 | + | "id": project["id"], | |
| 51 | + | "workspace": workspace, | |
| 52 | + | "slug": slug, | |
| 53 | + | "name": project["name"], | |
| 54 | + | "description": project["description"], | |
| 55 | + | "private": project["private"], | |
| 56 | + | "archived": project["archived"], | |
| 57 | + | "kind": project["kind"], | |
| 58 | + | "url": format!("{}/{workspace}/{slug}", site.trim_end_matches('/')), | |
| 59 | + | "pushed_at": project["pushedAt"], | |
| 60 | + | "updated_at": project["updatedAt"], | |
| 61 | + | }) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | fn pins_json(projects: &[Value], site: &str) -> Value { | |
| 65 | + | Value::Array(projects.iter().enumerate().map(|(at, project)| pin_json(project, at, site)).collect()) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /// Runs one of the pin operations. | |
| 69 | + | pub async fn run(op: Op, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 70 | + | let user = match viewer { | |
| 71 | + | Some(user) if user.kind == PrincipalKind::User => user, | |
| 72 | + | Some(_) => { | |
| 73 | + | return failed( | |
| 74 | + | FailureCode::Forbidden, | |
| 75 | + | "Pins are a person's own: use a personal access token, not a workspace's or an agent's.", | |
| 76 | + | ); | |
| 77 | + | } | |
| 78 | + | None => return failed(FailureCode::Unauthenticated, "This needs a g1t access token."), | |
| 79 | + | }; | |
| 80 | + | let Some(workspace) = text(input, "workspace") else { | |
| 81 | + | return failed(FailureCode::Invalid, "Give the workspace's slug."); | |
| 82 | + | }; | |
| 83 | + | let site = services.addresses.site.as_str(); | |
| 84 | + | let changed = |outcome: Outcome<Vec<Value>>| -> Result<Outcome<Value>> { | |
| 85 | + | Ok(match outcome { | |
| 86 | + | Outcome::Ok(projects) => Outcome::Ok(pins_json(&projects, site)), | |
| 87 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 88 | + | }) | |
| 89 | + | }; | |
| 90 | + | match op { | |
| 91 | + | Op::ListPinnedProjects => { | |
| 92 | + | let shortcuts: Value = g1t_kit::call( | |
| 93 | + | &services.projects, | |
| 94 | + | "shortcuts", | |
| 95 | + | &json!({ "workspace": workspace, "viewer": viewer }), | |
| 96 | + | ) | |
| 97 | + | .await?; | |
| 98 | + | let pinned = shortcuts["pinned"].as_array().cloned().unwrap_or_default(); | |
| 99 | + | Ok(Outcome::Ok(pins_json(&pinned, site))) | |
| 100 | + | } | |
| 101 | + | Op::PinProject | Op::UnpinProject => { | |
| 102 | + | let Some(slug) = text(input, "project") else { | |
| 103 | + | return failed(FailureCode::Invalid, "Give the project's slug."); | |
| 104 | + | }; | |
| 105 | + | let (method, args) = if op == Op::PinProject { | |
| 106 | + | ("pin", json!({ "actor": user, "workspace": workspace, "slug": slug, "position": position(input) })) | |
| 107 | + | } else { | |
| 108 | + | ("unpin", json!({ "actor": user, "workspace": workspace, "slug": slug })) | |
| 109 | + | }; | |
| 110 | + | changed(g1t_kit::call(&services.projects, method, &args).await?) | |
| 111 | + | } | |
| 112 | + | Op::ReorderPinnedProjects => { | |
| 113 | + | let Some(order) = slugs(input) else { | |
| 114 | + | return failed(FailureCode::Invalid, "Give projects: every pinned project's slug, in the order you want them."); | |
| 115 | + | }; | |
| 116 | + | changed( | |
| 117 | + | g1t_kit::call( | |
| 118 | + | &services.projects, | |
| 119 | + | "reorder_pins", | |
| 120 | + | &json!({ "actor": user, "workspace": workspace, "slugs": order }), | |
| 121 | + | ) | |
| 122 | + | .await?, | |
| 123 | + | ) | |
| 124 | + | } | |
| 125 | + | _ => failed(FailureCode::Invalid, "Not a pin operation."), | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | #[cfg(test)] | |
| 130 | + | mod tests { | |
| 131 | + | use super::*; | |
| 132 | + | ||
| 133 | + | #[test] | |
| 134 | + | fn a_pin_is_snake_case_with_its_place_and_address() { | |
| 135 | + | let project = json!({ | |
| 136 | + | "id": "prj_1", "workspace": "acme", "slug": "web", "name": "Web", "description": null, | |
| 137 | + | "private": true, "archived": false, "kind": "app", "pushedAt": "2026-10-07T10:00:00.000Z", | |
| 138 | + | "updatedAt": "2026-10-01T10:00:00.000Z", "descriptionInherited": false, | |
| 139 | + | }); | |
| 140 | + | let pin = pin_json(&project, 2, "https://g1t.sh/"); | |
| 141 | + | assert_eq!(pin["position"], 2); | |
| 142 | + | assert_eq!(pin["url"], "https://g1t.sh/acme/web"); | |
| 143 | + | assert_eq!(pin["pushed_at"], "2026-10-07T10:00:00.000Z"); | |
| 144 | + | assert!(pin.get("pushedAt").is_none()); | |
| 145 | + | assert!(pin.get("descriptionInherited").is_none()); | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | #[test] | |
| 149 | + | fn the_order_is_read_from_slugs_or_objects() { | |
| 150 | + | assert_eq!(slugs(&json!({ "projects": ["Web", { "slug": "api" }, ""] })), Some(vec!["web".into(), "api".into()])); | |
| 151 | + | assert_eq!(slugs(&json!({})), None); | |
| 152 | + | } | |
| 153 | + | } |
| 43 | 43 | ], | |
| 44 | 44 | "token": { | |
| 45 | 45 | "token_id": "tok_01kkntd3p2v8x6ym5r0c1q7a9e", | |
| 46 | − | "scopes": ["repo:read", "issues:read", "issues:write", "pull_requests:read", "pull_requests:write"] | |
| 46 | + | "scopes": [ | |
| 47 | + | "repo:read", | |
| 48 | + | "issues:read", | |
| 49 | + | "issues:write", | |
| 50 | + | "pull_requests:read", | |
| 51 | + | "pull_requests:write" | |
| 52 | + | ] | |
| 47 | 53 | } | |
| 48 | 54 | }, | |
| 49 | 55 | "notes": "`token` is what the access token you called with may do: its `scopes` (left out for full access) and `legacy` when it was made before tokens had scopes. A token reaches every workspace and repository whoever it acts as can; its scopes say what it may do there. See [Scopes](/guides/authentication/#scopes). `kind` is `user`, `workspace` for a [workspace access token](/guides/workspaces/#workspace-access-tokens), or `agent` for the token a g1t agent works with. For a workspace token, `username` is the workspace's slug and `workspaces` holds only that workspace. Each workspace carries its `base_permission`: what a member gets on each of its repositories (owners have Admin). Roles given on single repositories are in `grants`, each with `repo_id`, `workspace` and `role`; it is left out when there are none. See [Access and roles](/guides/access-and-roles/)." | |
| 4977 | 4983 | "updated_at": "2026-10-05T14:30:00.000Z" | |
| 4978 | 4984 | } | |
| 4979 | 4985 | ] | |
| 4986 | + | }, | |
| 4987 | + | "list_pinned_projects": { | |
| 4988 | + | "params": { | |
| 4989 | + | "workspace": "flagon-io" | |
| 4990 | + | }, | |
| 4991 | + | "response": [ | |
| 4992 | + | { | |
| 4993 | + | "position": 0, | |
| 4994 | + | "id": "prj_01kkp3m8w2f6t9qh4c7d1r5n0x", | |
| 4995 | + | "workspace": "flagon-io", | |
| 4996 | + | "slug": "g1t", | |
| 4997 | + | "name": "g1t", | |
| 4998 | + | "description": null, | |
| 4999 | + | "private": false, | |
| 5000 | + | "archived": false, | |
| 5001 | + | "kind": "app", | |
| 5002 | + | "url": "https://g1t.sh/flagon-io/g1t", | |
| 5003 | + | "pushed_at": "2026-10-07T10:00:00.000Z", | |
| 5004 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5005 | + | }, | |
| 5006 | + | { | |
| 5007 | + | "position": 1, | |
| 5008 | + | "id": "prj_01kkq0c4d9h2w7rn5f8t3k6p2z", | |
| 5009 | + | "workspace": "flagon-io", | |
| 5010 | + | "slug": "lab-api", | |
| 5011 | + | "name": "lab-api", | |
| 5012 | + | "description": null, | |
| 5013 | + | "private": true, | |
| 5014 | + | "archived": false, | |
| 5015 | + | "kind": "app", | |
| 5016 | + | "url": "https://g1t.sh/flagon-io/lab-api", | |
| 5017 | + | "pushed_at": "2026-10-06T18:40:00.000Z", | |
| 5018 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5019 | + | }, | |
| 5020 | + | { | |
| 5021 | + | "position": 2, | |
| 5022 | + | "id": "prj_01kkp3n2a7e5v8sk3b6g9j4m1y", | |
| 5023 | + | "workspace": "flagon-io", | |
| 5024 | + | "slug": "hello", | |
| 5025 | + | "name": "hello", | |
| 5026 | + | "description": "Greets people from the command line.", | |
| 5027 | + | "private": false, | |
| 5028 | + | "archived": false, | |
| 5029 | + | "kind": "library", | |
| 5030 | + | "url": "https://g1t.sh/flagon-io/hello", | |
| 5031 | + | "pushed_at": null, | |
| 5032 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5033 | + | } | |
| 5034 | + | ], | |
| 5035 | + | "notes": "In your order: `position` 0 first. The sidebar shows these, then the projects you opened last. `pushed_at` is null until the project's repository is pushed to." | |
| 5036 | + | }, | |
| 5037 | + | "pin_project": { | |
| 5038 | + | "params": { | |
| 5039 | + | "workspace": "flagon-io", | |
| 5040 | + | "project": "hello" | |
| 5041 | + | }, | |
| 5042 | + | "request": { | |
| 5043 | + | "position": 0 | |
| 5044 | + | }, | |
| 5045 | + | "response": [ | |
| 5046 | + | { | |
| 5047 | + | "position": 0, | |
| 5048 | + | "id": "prj_01kkp3n2a7e5v8sk3b6g9j4m1y", | |
| 5049 | + | "workspace": "flagon-io", | |
| 5050 | + | "slug": "hello", | |
| 5051 | + | "name": "hello", | |
| 5052 | + | "description": "Greets people from the command line.", | |
| 5053 | + | "private": false, | |
| 5054 | + | "archived": false, | |
| 5055 | + | "kind": "library", | |
| 5056 | + | "url": "https://g1t.sh/flagon-io/hello", | |
| 5057 | + | "pushed_at": null, | |
| 5058 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5059 | + | }, | |
| 5060 | + | { | |
| 5061 | + | "position": 1, | |
| 5062 | + | "id": "prj_01kkp3m8w2f6t9qh4c7d1r5n0x", | |
| 5063 | + | "workspace": "flagon-io", | |
| 5064 | + | "slug": "g1t", | |
| 5065 | + | "name": "g1t", | |
| 5066 | + | "description": null, | |
| 5067 | + | "private": false, | |
| 5068 | + | "archived": false, | |
| 5069 | + | "kind": "app", | |
| 5070 | + | "url": "https://g1t.sh/flagon-io/g1t", | |
| 5071 | + | "pushed_at": "2026-10-07T10:00:00.000Z", | |
| 5072 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5073 | + | }, | |
| 5074 | + | { | |
| 5075 | + | "position": 2, | |
| 5076 | + | "id": "prj_01kkq0c4d9h2w7rn5f8t3k6p2z", | |
| 5077 | + | "workspace": "flagon-io", | |
| 5078 | + | "slug": "lab-api", | |
| 5079 | + | "name": "lab-api", | |
| 5080 | + | "description": null, | |
| 5081 | + | "private": true, | |
| 5082 | + | "archived": false, | |
| 5083 | + | "kind": "app", | |
| 5084 | + | "url": "https://g1t.sh/flagon-io/lab-api", | |
| 5085 | + | "pushed_at": "2026-10-06T18:40:00.000Z", | |
| 5086 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5087 | + | } | |
| 5088 | + | ], | |
| 5089 | + | "notes": "A ninth pin in a workspace is refused with `409 conflict`: unpin one first." | |
| 5090 | + | }, | |
| 5091 | + | "unpin_project": { | |
| 5092 | + | "params": { | |
| 5093 | + | "workspace": "flagon-io", | |
| 5094 | + | "project": "lab-api" | |
| 5095 | + | }, | |
| 5096 | + | "response": [ | |
| 5097 | + | { | |
| 5098 | + | "position": 0, | |
| 5099 | + | "id": "prj_01kkp3m8w2f6t9qh4c7d1r5n0x", | |
| 5100 | + | "workspace": "flagon-io", | |
| 5101 | + | "slug": "g1t", | |
| 5102 | + | "name": "g1t", | |
| 5103 | + | "description": null, | |
| 5104 | + | "private": false, | |
| 5105 | + | "archived": false, | |
| 5106 | + | "kind": "app", | |
| 5107 | + | "url": "https://g1t.sh/flagon-io/g1t", | |
| 5108 | + | "pushed_at": "2026-10-07T10:00:00.000Z", | |
| 5109 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5110 | + | }, | |
| 5111 | + | { | |
| 5112 | + | "position": 1, | |
| 5113 | + | "id": "prj_01kkp3n2a7e5v8sk3b6g9j4m1y", | |
| 5114 | + | "workspace": "flagon-io", | |
| 5115 | + | "slug": "hello", | |
| 5116 | + | "name": "hello", | |
| 5117 | + | "description": "Greets people from the command line.", | |
| 5118 | + | "private": false, | |
| 5119 | + | "archived": false, | |
| 5120 | + | "kind": "library", | |
| 5121 | + | "url": "https://g1t.sh/flagon-io/hello", | |
| 5122 | + | "pushed_at": null, | |
| 5123 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5124 | + | } | |
| 5125 | + | ] | |
| 5126 | + | }, | |
| 5127 | + | "reorder_pinned_projects": { | |
| 5128 | + | "params": { | |
| 5129 | + | "workspace": "flagon-io" | |
| 5130 | + | }, | |
| 5131 | + | "request": { | |
| 5132 | + | "projects": [ | |
| 5133 | + | "lab-api", | |
| 5134 | + | "g1t", | |
| 5135 | + | "hello" | |
| 5136 | + | ] | |
| 5137 | + | }, | |
| 5138 | + | "response": [ | |
| 5139 | + | { | |
| 5140 | + | "position": 0, | |
| 5141 | + | "id": "prj_01kkq0c4d9h2w7rn5f8t3k6p2z", | |
| 5142 | + | "workspace": "flagon-io", | |
| 5143 | + | "slug": "lab-api", | |
| 5144 | + | "name": "lab-api", | |
| 5145 | + | "description": null, | |
| 5146 | + | "private": true, | |
| 5147 | + | "archived": false, | |
| 5148 | + | "kind": "app", | |
| 5149 | + | "url": "https://g1t.sh/flagon-io/lab-api", | |
| 5150 | + | "pushed_at": "2026-10-06T18:40:00.000Z", | |
| 5151 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5152 | + | }, | |
| 5153 | + | { | |
| 5154 | + | "position": 1, | |
| 5155 | + | "id": "prj_01kkp3m8w2f6t9qh4c7d1r5n0x", | |
| 5156 | + | "workspace": "flagon-io", | |
| 5157 | + | "slug": "g1t", | |
| 5158 | + | "name": "g1t", | |
| 5159 | + | "description": null, | |
| 5160 | + | "private": false, | |
| 5161 | + | "archived": false, | |
| 5162 | + | "kind": "app", | |
| 5163 | + | "url": "https://g1t.sh/flagon-io/g1t", | |
| 5164 | + | "pushed_at": "2026-10-07T10:00:00.000Z", | |
| 5165 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5166 | + | }, | |
| 5167 | + | { | |
| 5168 | + | "position": 2, | |
| 5169 | + | "id": "prj_01kkp3n2a7e5v8sk3b6g9j4m1y", | |
| 5170 | + | "workspace": "flagon-io", | |
| 5171 | + | "slug": "hello", | |
| 5172 | + | "name": "hello", | |
| 5173 | + | "description": "Greets people from the command line.", | |
| 5174 | + | "private": false, | |
| 5175 | + | "archived": false, | |
| 5176 | + | "kind": "library", | |
| 5177 | + | "url": "https://g1t.sh/flagon-io/hello", | |
| 5178 | + | "pushed_at": null, | |
| 5179 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5180 | + | } | |
| 5181 | + | ], | |
| 5182 | + | "notes": "Name every pinned project once; anything else is refused with `422 invalid`." | |
| 4980 | 5183 | } | |
| 4981 | 5184 | } |
| 79 | 79 | route("PUT", "/repos/:owner/:name/issues/:number/subscription", Op::SetThreadSubscription, &[]), | |
| 80 | 80 | route("DELETE", "/repos/:owner/:name/issues/:number/subscription", Op::DeleteThreadSubscription, &[]), | |
| 81 | 81 | route("GET", "/user/subscriptions", Op::ListWatchedRepos, &[]), | |
| 82 | + | // Your pinned projects in a workspace, in your order. | |
| 83 | + | route("GET", "/user/pinned_projects/:workspace", Op::ListPinnedProjects, &[]), | |
| 84 | + | route("PUT", "/user/pinned_projects/:workspace", Op::ReorderPinnedProjects, &[]), | |
| 85 | + | route("PUT", "/user/pinned_projects/:workspace/:project", Op::PinProject, &[]), | |
| 86 | + | route("DELETE", "/user/pinned_projects/:workspace/:project", Op::UnpinProject, &[]), | |
| 82 | 87 | route("PATCH", "/user/repository_invitations/:id", Op::AcceptRepoInvitation, &[]), | |
| 83 | 88 | route("DELETE", "/user/repository_invitations/:id", Op::DeclineRepoInvitation, &[]), | |
| 84 | 89 | route("PATCH", "/workspaces/:workspace", Op::UpdateWorkspace, &[]), |
| 227 | 227 | Tool { | |
| 228 | 228 | name: "workspace", | |
| 229 | 229 | title: "Workspaces", | |
| 230 | − | description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): create, update or delete one, invite members, and connect integrations and model providers.", | |
| 230 | + | description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): create, update or delete one, invite members, connect integrations and model providers, and keep your own pinned projects at the top of its sidebar.", | |
| 231 | 231 | default_action: None, | |
| 232 | 232 | actions: &[ | |
| 233 | 233 | a("create", Op::CreateWorkspace, "Create a workspace"), | |
| 242 | 242 | a("test_integration", Op::TestIntegration, "Check its credentials"), | |
| 243 | 243 | a("get_model_routes", Op::GetModelRoutes, "Where each kind of work's model requests go"), | |
| 244 | 244 | a("set_model_routes", Op::SetModelRoutes, "Replace them"), | |
| 245 | + | a("list_pinned_projects", Op::ListPinnedProjects, "Your pinned projects in it, in your order"), | |
| 246 | + | a("pin_project", Op::PinProject, "Pin a project, at a position or the end"), | |
| 247 | + | a("unpin_project", Op::UnpinProject, "Unpin a project"), | |
| 248 | + | a("reorder_pinned_projects", Op::ReorderPinnedProjects, "Put your pins in a new order"), | |
| 245 | 249 | ], | |
| 246 | 250 | }, | |
| 247 | 251 | Tool { |
| 29 | 29 | { "binding": "CONTEXT", "service": "g1t-context" }, | |
| 30 | 30 | { "binding": "SEARCH", "service": "g1t-search" }, | |
| 31 | 31 | // Secret and dependency alerts: list_security_alerts and dismissing them. | |
| 32 | − | { "binding": "SECURITY", "service": "g1t-security" } | |
| 32 | + | { "binding": "SECURITY", "service": "g1t-security" }, | |
| 33 | + | // A person's pinned projects: list_pinned_projects and changing them. | |
| 34 | + | { "binding": "PROJECTS", "service": "g1t-projects" } | |
| 33 | 35 | ], | |
| 34 | 36 | // GitHub Actions artifacts, in chunks, with KV's own expiry (and cache | |
| 35 | 37 | // entries saved before the cache moved to R2, until they expire). |
| 351 | 351 | | `workflows:write` | Run, cancel, rerun and turn workflows on or off | | |
| 352 | 352 | | `memory:read` | Recall memory and search the workspace's context | | |
| 353 | 353 | | `memory:write` | Save memory for the next agent | | |
| 354 | − | | `account:read` | Read your email addresses, invites and invitations | | |
| 355 | − | | `account:write` | Change your email addresses, make invites and answer invitations | | |
| 354 | + | | `account:read` | Read your email addresses, invites, invitations and pinned projects | | |
| 355 | + | | `account:write` | Change your email addresses, make invites, answer invitations and pin projects | | |
| 356 | 356 | | `notifications:read` | See your [inbox](/guides/inbox/), its threads, and what you subscribe to and watch | | |
| 357 | 357 | | `notifications:write` | Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories | | |
| 358 | 358 | | `workspace:read` | Read workspace invites, integrations and model routes | |
Binary or large file; its contents are not shown.
| 268 | 268 | "get_thread_subscription", | |
| 269 | 269 | "get_repo_subscription", | |
| 270 | 270 | "list_watched_repos", | |
| 271 | + | "list_pinned_projects", | |
| 271 | 272 | ]; | |
| 272 | 273 | ||
| 273 | 274 | /// What no agent's token may ever do, whatever its scope says: workspaces, | |
| 349 | 350 | "set_repo_subscription", | |
| 350 | 351 | "delete_repo_subscription", | |
| 351 | 352 | "list_watched_repos", | |
| 353 | + | // Pins are a person's own, as the inbox is. | |
| 354 | + | "list_pinned_projects", | |
| 355 | + | "pin_project", | |
| 356 | + | "unpin_project", | |
| 357 | + | "reorder_pinned_projects", | |
| 352 | 358 | ]; | |
| 353 | 359 | ||
| 354 | 360 | /// Reading what an agent needs to know about its repository. |
| 288 | 288 | Scope::WorkflowsWrite => "Run, cancel, rerun and turn workflows on or off", | |
| 289 | 289 | Scope::MemoryRead => "Recall memory and search the workspace's context", | |
| 290 | 290 | Scope::MemoryWrite => "Save memory for the next agent", | |
| 291 | − | Scope::AccountRead => "Read your email addresses, invites and invitations", | |
| 292 | − | Scope::AccountWrite => "Change your email addresses, make invites and answer invitations", | |
| 291 | + | Scope::AccountRead => "Read your email addresses, invites, invitations and pinned projects", | |
| 292 | + | Scope::AccountWrite => "Change your email addresses, make invites, answer invitations and pin projects", | |
| 293 | 293 | Scope::NotificationsRead => "See your inbox, its threads, and what you subscribe to and watch", | |
| 294 | 294 | Scope::NotificationsWrite => "Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories", | |
| 295 | 295 | Scope::WorkspaceRead => "Read workspace invites, integrations and model routes", | |
| 475 | 475 | ("list_my_repo_invitations", Scope::AccountRead), | |
| 476 | 476 | ("accept_repo_invitation", Scope::AccountWrite), | |
| 477 | 477 | ("decline_repo_invitation", Scope::AccountWrite), | |
| 478 | + | // Your pinned projects: a preference of your account. | |
| 479 | + | ("list_pinned_projects", Scope::AccountRead), | |
| 480 | + | ("pin_project", Scope::AccountWrite), | |
| 481 | + | ("unpin_project", Scope::AccountWrite), | |
| 482 | + | ("reorder_pinned_projects", Scope::AccountWrite), | |
| 478 | 483 | // Your inbox: notifications, subscriptions and watching. | |
| 479 | 484 | ("list_notifications", Scope::NotificationsRead), | |
| 480 | 485 | ("get_notification_thread", Scope::NotificationsRead), |
| 46 | 46 | { scope: "workflows:write", description: "Run, cancel, rerun and turn workflows on or off" }, | |
| 47 | 47 | { scope: "memory:read", description: "Recall memory and search the workspace's context" }, | |
| 48 | 48 | { scope: "memory:write", description: "Save memory for the next agent" }, | |
| 49 | − | { scope: "account:read", description: "Read your email addresses, invites and invitations" }, | |
| 50 | − | { scope: "account:write", description: "Change your email addresses, make invites and answer invitations" }, | |
| 49 | + | { scope: "account:read", description: "Read your email addresses, invites, invitations and pinned projects" }, | |
| 50 | + | { scope: "account:write", description: "Change your email addresses, make invites, answer invitations and pin projects" }, | |
| 51 | 51 | { scope: "notifications:read", description: "See your inbox, its threads, and what you subscribe to and watch" }, | |
| 52 | 52 | { scope: "notifications:write", description: "Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories" }, | |
| 53 | 53 | { scope: "workspace:read", description: "Read workspace invites, integrations and model routes" }, | |
| 175 | 175 | ["list_my_repo_invitations", "account:read"], | |
| 176 | 176 | ["accept_repo_invitation", "account:write"], | |
| 177 | 177 | ["decline_repo_invitation", "account:write"], | |
| 178 | + | // Your pinned projects: a preference of your account. | |
| 179 | + | ["list_pinned_projects", "account:read"], | |
| 180 | + | ["pin_project", "account:write"], | |
| 181 | + | ["unpin_project", "account:write"], | |
| 182 | + | ["reorder_pinned_projects", "account:write"], | |
| 178 | 183 | // Your inbox: notifications, subscriptions and watching. | |
| 179 | 184 | ["list_notifications", "notifications:read"], | |
| 180 | 185 | ["get_notification_thread", "notifications:read"], |